feat: base implementation of panel preview
This commit is contained in:
@@ -58,4 +58,10 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-snippet {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ImageManager from "$components/image/ImageManager.svelte";
|
import ImageManager from "$components/image/ImageManager.svelte";
|
||||||
import PanelPreview from "$components/panel/PanelPreview.svelte";
|
import PreviewManager from "$components/panel/PreviewManager.svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="panel-bar">
|
<div class="panel-bar">
|
||||||
<ImageManager />
|
<ImageManager />
|
||||||
<PanelPreview />
|
<PreviewManager />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Card from "$components/layout/Card.svelte";
|
|
||||||
import Badge from "$components/ui/Badge.svelte";
|
|
||||||
import IconArrowLeft from "$components/ui/Icons/IconArrowLeft.svelte";
|
|
||||||
import IconArrowRight from "$components/ui/Icons/IconArrowRight.svelte";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet panelTitle()}
|
|
||||||
Панели <Badge text="0" />
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#snippet panelControls()}
|
|
||||||
<button id="prevPanel" class="nav-btn" disabled>
|
|
||||||
<IconArrowLeft />
|
|
||||||
</button>
|
|
||||||
<span id="panelIndicator" class="panel-indicator">0 / 0</span>
|
|
||||||
<button id="nextPanel" class="nav-btn" disabled>
|
|
||||||
<IconArrowRight />
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<Card title={panelTitle} titleSnippet={panelControls}>Панели</Card>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { SlideDirection } from "$lib/types/utils";
|
||||||
|
import { fly } from "svelte/transition";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string;
|
||||||
|
direction: SlideDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { text, direction }: Props = $props();
|
||||||
|
|
||||||
|
let xDirection = $derived(direction == "next" ? 320 : -320);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div in:fly={{ x: xDirection, duration: 300 }} out:fly={{ x: -xDirection, duration: 300 }}>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Button from "$components/ui/Button.svelte";
|
||||||
|
import IconArrowLeft from "$components/ui/Icons/IconArrowLeft.svelte";
|
||||||
|
import IconArrowRight from "$components/ui/Icons/IconArrowRight.svelte";
|
||||||
|
import type { SlideDirection } from "$lib/types/utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
current: number;
|
||||||
|
direction: SlideDirection;
|
||||||
|
max: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { current = $bindable(), direction = $bindable("next"), max }: Props = $props();
|
||||||
|
|
||||||
|
let isFirst = $derived(current === 0);
|
||||||
|
let isLast = $derived(current === max - 1);
|
||||||
|
|
||||||
|
function prev() {
|
||||||
|
if (current > 0) current--;
|
||||||
|
direction = "prev";
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
if (current < max - 1) current++;
|
||||||
|
direction = "next";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button icon={IconArrowLeft} type="mini" onclick={prev} disabled={isFirst} />
|
||||||
|
<span class="panel-indicator">{current + 1} / {max}</span>
|
||||||
|
<Button icon={IconArrowRight} type="mini" onclick={next} disabled={isLast} />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.panel-indicator {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 50px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Card from "$components/layout/Card.svelte";
|
||||||
|
import Badge from "$components/ui/Badge.svelte";
|
||||||
|
import Button from "$components/ui/Button.svelte";
|
||||||
|
import IconDownload from "$components/ui/Icons/IconDownload.svelte";
|
||||||
|
import IconEmpty from "$components/ui/Icons/IconEmpty.svelte";
|
||||||
|
import type { SlideDirection } from "$lib/types/utils";
|
||||||
|
import { textsState } from "$states/texts.svelte";
|
||||||
|
import Preview from "./Preview.svelte";
|
||||||
|
import PreviewControls from "./PreviewControls.svelte";
|
||||||
|
|
||||||
|
let current: number = $state(0);
|
||||||
|
let direction: SlideDirection = $state("next");
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
$inspect(current, textsState.texts);
|
||||||
|
if (current > textsState.texts.length - 1) current = textsState.texts.length - 1;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet panelTitle()}
|
||||||
|
Панели <Badge text={textsState.texts.length} />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet panelControls()}
|
||||||
|
<PreviewControls bind:current bind:direction max={textsState.texts.length} />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<Card title={panelTitle} titleSnippet={panelControls}>
|
||||||
|
<div class="panel-viewer">
|
||||||
|
<div class="panel-display">
|
||||||
|
{#if textsState.texts.length}
|
||||||
|
<!-- {@const text = } -->
|
||||||
|
{#key current}
|
||||||
|
<Preview text={textsState.texts[current].text} {direction} />
|
||||||
|
{/key}
|
||||||
|
{:else}
|
||||||
|
<div class="empty-state">
|
||||||
|
<IconEmpty />
|
||||||
|
<p>Добавьте тексты для создания панелей</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-actions">
|
||||||
|
<Button label="Скачать всё" icon={IconDownload} />
|
||||||
|
<Button label="Скачать" type="outline" icon={IconDownload} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.panel-viewer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-display {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px 16px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state :global(svg) {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin: 0 auto 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
text: string;
|
text: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { text }: Props = $props();
|
let { text }: Props = $props();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
onclick?: MouseEventHandler<HTMLButtonElement>;
|
onclick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
type?: "primary" | "secondary" | "danger" | "outline";
|
type?: "primary" | "secondary" | "danger" | "outline" | "mini";
|
||||||
}
|
}
|
||||||
|
|
||||||
let { icon: Icon, onclick = () => {}, disabled = false, label = "", type = "primary" }: Props = $props();
|
let { icon: Icon, onclick = () => {}, disabled = false, label = "", type = "primary" }: Props = $props();
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
class:btn-secondary={type === "secondary"}
|
class:btn-secondary={type === "secondary"}
|
||||||
class:btn-outline={type === "outline"}
|
class:btn-outline={type === "outline"}
|
||||||
class:btn-danger={type === "danger"}
|
class:btn-danger={type === "danger"}
|
||||||
|
class:btn-mini={type === "mini"}
|
||||||
{disabled}
|
{disabled}
|
||||||
{onclick}
|
{onclick}
|
||||||
>
|
>
|
||||||
@@ -47,20 +48,20 @@
|
|||||||
height: 16px;
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn:hover:not(:disabled) {
|
||||||
background: var(--accent-primary);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
|
||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:disabled {
|
.btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -83,11 +84,6 @@
|
|||||||
color: var(--accent-primary);
|
color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background: var(--danger);
|
background: var(--danger);
|
||||||
color: white;
|
color: white;
|
||||||
@@ -107,4 +103,20 @@
|
|||||||
.btn-danger:hover {
|
.btn-danger:hover {
|
||||||
background: #dc2626;
|
background: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-mini {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mini:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export type SlideDirection = "next" | "prev";
|
||||||
|
|
||||||
export type TextAlign = "left" | "center" | "right";
|
export type TextAlign = "left" | "center" | "right";
|
||||||
|
|
||||||
export type HexColor = `#${string}`;
|
export type HexColor = `#${string}`;
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import PanelPreview from "$components/panel/PanelPreview.svelte";
|
|
||||||
import type { Panel } from "$lib/types/panel";
|
|
||||||
import { fireEvent, render, screen } from "@testing-library/svelte";
|
|
||||||
import type { Stage as KonvaStage } from "konva/lib/Stage";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
// Mock the UI store
|
|
||||||
vi.mock("$stores/uiStore", () => ({
|
|
||||||
setCurrentStep: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("PanelPreview", () => {
|
|
||||||
let mockPanel: Panel;
|
|
||||||
let mockOnDownload: (panel: Panel, konvaStage: KonvaStage) => void;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockPanel = {
|
|
||||||
id: "test-panel-1",
|
|
||||||
backgroundImage: "/backgrounds/b1.jpg",
|
|
||||||
text: {
|
|
||||||
id: "test-text-1",
|
|
||||||
text: "Test Panel",
|
|
||||||
fontSize: 18,
|
|
||||||
fontFamily: "Arial",
|
|
||||||
color: "#ffffff",
|
|
||||||
textAlign: "center",
|
|
||||||
paddingX: 10,
|
|
||||||
verticalOffset: 0,
|
|
||||||
},
|
|
||||||
height: 100,
|
|
||||||
createdAt: new Date("2024-01-01"),
|
|
||||||
updatedAt: new Date("2024-01-01"),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockOnDownload = vi.fn();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render panel with text content", () => {
|
|
||||||
const { container } = render(PanelPreview, {
|
|
||||||
props: {
|
|
||||||
panel: mockPanel,
|
|
||||||
onDownload: mockOnDownload,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should render without errors
|
|
||||||
expect(container).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should call onDownload when download button is clicked", async () => {
|
|
||||||
render(PanelPreview, {
|
|
||||||
props: {
|
|
||||||
panel: mockPanel,
|
|
||||||
onDownload: mockOnDownload,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Find and click download button - button text might vary, so we'll look for a button
|
|
||||||
const buttons = screen.getAllByRole("button");
|
|
||||||
const downloadButton = buttons.find(
|
|
||||||
(button) => button.textContent?.includes("Скачать") || button.textContent?.includes("Download"),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (downloadButton) {
|
|
||||||
await fireEvent.click(downloadButton);
|
|
||||||
// Should call onDownload with panel and konva stage
|
|
||||||
expect(mockOnDownload).toHaveBeenCalledWith(mockPanel, expect.any(Object));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle panel without onDownload callback", () => {
|
|
||||||
const { container } = render(PanelPreview, {
|
|
||||||
props: {
|
|
||||||
panel: mockPanel,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should render without errors
|
|
||||||
expect(container).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display panel text content", () => {
|
|
||||||
render(PanelPreview, {
|
|
||||||
props: {
|
|
||||||
panel: mockPanel,
|
|
||||||
onDownload: mockOnDownload,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if panel text is rendered (this might be in a canvas, so we check container)
|
|
||||||
const { container } = render(PanelPreview, {
|
|
||||||
props: {
|
|
||||||
panel: mockPanel,
|
|
||||||
onDownload: mockOnDownload,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(container).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user