feat: base implementation of panel preview

This commit is contained in:
2026-02-06 12:55:48 +05:00
parent 070b49cbad
commit 571fd9bf25
10 changed files with 184 additions and 140 deletions
+6
View File
@@ -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>
+2 -2
View File
@@ -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>
-25
View File
@@ -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>
+17
View File
@@ -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 -1
View File
@@ -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();
+25 -13
View File
@@ -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();
});
});