From f317ac1030fdfc97b589b86fce140630fcbed0f9 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Tue, 3 Feb 2026 21:54:10 +0500 Subject: [PATCH] feat: continue impemelent mvp components and stores --- plans/implementation-plan.md | 38 +-- src/components/ImageCropper.svelte | 227 ++++++++++++++++++ src/components/PanelList.svelte | 285 ++++++++++++++++++++++ src/components/PanelPreview.svelte | 155 ++++++++++++ src/components/TextManager.svelte | 367 +++++++++++++++++++++++++++++ src/lib/services/exportService.ts | 96 ++++++++ src/lib/utils/panelStorage.ts | 158 +++++++++++++ 7 files changed, 1307 insertions(+), 19 deletions(-) create mode 100644 src/components/ImageCropper.svelte create mode 100644 src/components/PanelList.svelte create mode 100644 src/components/PanelPreview.svelte create mode 100644 src/components/TextManager.svelte create mode 100644 src/lib/services/exportService.ts create mode 100644 src/lib/utils/panelStorage.ts diff --git a/plans/implementation-plan.md b/plans/implementation-plan.md index 6d4a793..f858112 100644 --- a/plans/implementation-plan.md +++ b/plans/implementation-plan.md @@ -59,35 +59,35 @@ src/ ### **Image Cropping Interface** -- [ ] Integrate cropperjs library -- [ ] Create responsive crop interface -- [ ] Add crop ratio constraints (320px width) -- [ ] Implement crop confirmation and cancellation -- [ ] Handle crop errors and edge cases +- [x] Integrate cropperjs library +- [x] Create responsive crop interface +- [x] Add crop ratio constraints (320px width) +- [x] Implement crop confirmation and cancellation +- [x] Handle crop errors and edge cases ### **Text Management System** -- [ ] Create dynamic text list component -- [ ] Implement add/edit/delete text functionality -- [ ] Add text positioning controls -- [ ] Implement text styling options (font, size, color) -- [ ] Add text validation and error handling +- [x] Create dynamic text list component +- [x] Implement add/edit/delete text functionality +- [x] Add text positioning controls +- [x] Implement text styling options (font, size, color) +- [x] Add text validation and error handling ### **Canvas Rendering Engine** -- [ ] Upgrade SvelteKonva implementation -- [ ] Create dynamic height support -- [ ] Implement real-time preview updates -- [ ] Add layer management system +- [x] Upgrade SvelteKonva implementation +- [x] Create dynamic height support +- [x] Implement real-time preview updates +- [x] Add layer management system - [ ] Optimize rendering performance ### **Panel Management** -- [ ] Create panel storage system -- [ ] Implement panel navigation (previous/next) -- [ ] Add panel deletion functionality -- [ ] Create panel export queue -- [ ] Implement panel validation +- [x] Create panel storage system +- [x] Implement panel navigation (previous/next) +- [x] Add panel deletion functionality +- [x] Create panel export queue +- [x] Implement panel validation ### **Batch Download System** diff --git a/src/components/ImageCropper.svelte b/src/components/ImageCropper.svelte new file mode 100644 index 0000000..9c5a34e --- /dev/null +++ b/src/components/ImageCropper.svelte @@ -0,0 +1,227 @@ + + + +
+
+ Image to crop +
+ + {#if errorMessage} +
+ Ошибка: {errorMessage} +
+ {/if} + +
+ + +
+ +
+

💡 Изображение будет обрезано до ширины 320px

+
+
+ + diff --git a/src/components/PanelList.svelte b/src/components/PanelList.svelte new file mode 100644 index 0000000..d05f472 --- /dev/null +++ b/src/components/PanelList.svelte @@ -0,0 +1,285 @@ + + + +
+
+

Сохраненные панели

+ +
+ + {#if errorMessage} +
+ {errorMessage} +
+ {/if} + + {#if panels.length === 0} +
+

Нет сохраненных панелей

+
+ {:else} +
+ {#each panels as panel (panel.id)} +
handlePanelSelect(panel)} + role="button" + tabindex="0" + > +
+ {#if panel.backgroundImage} + Panel preview + {:else} +
Нет изображения
+ {/if} +
+ +
+
+ ID: {panel.id.slice(0, 8)}... + {formatDate(panel.updatedAt)} +
+ +
+ +
+
+
+ {/each} +
+ {/if} +
+ + diff --git a/src/components/PanelPreview.svelte b/src/components/PanelPreview.svelte new file mode 100644 index 0000000..922c093 --- /dev/null +++ b/src/components/PanelPreview.svelte @@ -0,0 +1,155 @@ + + + +
+
+

Предпросмотр панели

+ {#if currentPanel} + + {/if} +
+ + {#if currentPanel && Stage} +
+ + + {#if backgroundImage} + + {/if} + {#each currentPanel.texts as textItem (textItem.id)} + + {/each} + + +
+ {:else} +
+

Нет данных для предпросмотра

+
+ {/if} +
+ + diff --git a/src/components/TextManager.svelte b/src/components/TextManager.svelte new file mode 100644 index 0000000..865178a --- /dev/null +++ b/src/components/TextManager.svelte @@ -0,0 +1,367 @@ + + + +
+
+

Управление текстом

+
+ +
+
+ + +
+ {#if errorMessage} +
+ {errorMessage} +
+ {/if} +
+ + {#if currentPanel && currentPanel.texts.length > 0} +
+ {#each currentPanel.texts as textItem (textItem.id)} +
handleSelectText(textItem.id)} + > +
+ {textItem.text} + +
+ + {#if selectedTextId === textItem.id} +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ {/if} +
+ {/each} +
+ {:else} +
+

Нет добавленного текста

+
+ {/if} +
+ + diff --git a/src/lib/services/exportService.ts b/src/lib/services/exportService.ts new file mode 100644 index 0000000..42b4435 --- /dev/null +++ b/src/lib/services/exportService.ts @@ -0,0 +1,96 @@ + +import { saveAs } from "file-saver"; +import type { Panel } from "../types/panel"; +import { ImageError } from "../types/errors"; +import { handleError, logError } from "../utils/errorHandler"; + +export interface ExportResult { + success: boolean; + error?: string; +} + +export class ExportService { + /** + * Экспортирует панель в изображение и сохраняет её + */ + async exportPanel(panel: Panel, filename?: string): Promise { + try { + if (!panel.backgroundImage) { + throw new ImageError("Нет фонового изображения для экспорта"); + } + + // Создаем canvas для экспорта + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + if (!ctx) { + throw new ImageError("Не удалось создать контекст canvas"); + } + + // Устанавливаем размеры canvas + canvas.width = 320; + canvas.height = panel.height; + + // Загружаем фоновое изображение + const bgImage = await this.loadImage(panel.backgroundImage); + + // Рисуем фон + ctx.drawImage(bgImage, 0, 0, 320, panel.height); + + // Рисуем текст + panel.texts.forEach((textItem) => { + ctx.font = `${textItem.fontSize}px ${textItem.fontFamily}`; + ctx.fillStyle = textItem.color; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(textItem.text, textItem.x, textItem.y); + }); + + // Конвертируем в blob и сохраняем + canvas.toBlob((blob) => { + if (!blob) { + throw new ImageError("Не удалось создать изображение"); + } + + const defaultFilename = `twitch-panel-${panel.id}.png`; + saveAs(blob, filename || defaultFilename); + }, "image/png"); + + return { + success: true, + }; + } catch (error) { + logError(error, "Panel export failed"); + return { + success: false, + error: handleError(error, "Ошибка экспорта панели"), + }; + } + } + + /** + * Загружает изображение из base64 строки + */ + private loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new ImageError("Не удалось загрузить изображение")); + img.src = src; + }); + } + + /** + * Экспортирует несколько панелей в ZIP архив + * (будет реализовано позже для batch download) + */ + async exportPanels(panels: Panel[]): Promise { + // TODO: Реализовать пакетный экспорт с использованием JSZip + return { + success: false, + error: "Пакетный экспорт еще не реализован", + }; + } +} + +export const exportService = new ExportService(); diff --git a/src/lib/utils/panelStorage.ts b/src/lib/utils/panelStorage.ts new file mode 100644 index 0000000..cd92b70 --- /dev/null +++ b/src/lib/utils/panelStorage.ts @@ -0,0 +1,158 @@ + +import type { Panel } from "../types/panel"; +import { ImageError } from "../types/errors"; +import { handleError, logError } from "./errorHandler"; + +const STORAGE_KEY = "twitch-panels"; +const MAX_PANELS = 50; // Максимальное количество сохраненных панелей + +export interface StorageResult { + success: boolean; + error?: string; +} + +export class PanelStorage { + /** + * Сохраняет панель в localStorage + */ + savePanel(panel: Panel): StorageResult { + try { + const panels = this.getAllPanels(); + + // Проверяем, существует ли панель с таким ID + const existingIndex = panels.findIndex((p) => p.id === panel.id); + + if (existingIndex >= 0) { + // Обновляем существующую панель + panels[existingIndex] = panel; + } else { + // Добавляем новую панель в начало списка + panels.unshift(panel); + + // Проверяем лимит количества панелей + if (panels.length > MAX_PANELS) { + panels.length = MAX_PANELS; + } + } + + localStorage.setItem(STORAGE_KEY, JSON.stringify(panels)); + + return { + success: true, + }; + } catch (error) { + logError(error, "Failed to save panel"); + return { + success: false, + error: handleError(error, "Ошибка сохранения панели"), + }; + } + } + + /** + * Загружает все панели из localStorage + */ + getAllPanels(): Panel[] { + try { + const data = localStorage.getItem(STORAGE_KEY); + if (!data) { + return []; + } + + const panels: Panel[] = JSON.parse(data); + + // Валидация загруженных панелей + return panels.filter((panel) => this.validatePanel(panel)); + } catch (error) { + logError(error, "Failed to load panels"); + return []; + } + } + + /** + * Загружает панель по ID + */ + getPanelById(id: string): Panel | null { + try { + const panels = this.getAllPanels(); + return panels.find((p) => p.id === id) || null; + } catch (error) { + logError(error, "Failed to get panel by id"); + return null; + } + } + + /** + * Удаляет панель по ID + */ + deletePanel(id: string): StorageResult { + try { + const panels = this.getAllPanels(); + const filteredPanels = panels.filter((p) => p.id !== id); + + localStorage.setItem(STORAGE_KEY, JSON.stringify(filteredPanels)); + + return { + success: true, + }; + } catch (error) { + logError(error, "Failed to delete panel"); + return { + success: false, + error: handleError(error, "Ошибка удаления панели"), + }; + } + } + + /** + * Очищает все сохраненные панели + */ + clearAll(): StorageResult { + try { + localStorage.removeItem(STORAGE_KEY); + + return { + success: true, + }; + } catch (error) { + logError(error, "Failed to clear panels"); + return { + success: false, + error: handleError(error, "Ошибка очистки хранилища"), + }; + } + } + + /** + * Валидирует панель + */ + private validatePanel(panel: any): panel is Panel { + return ( + typeof panel === "object" && + typeof panel.id === "string" && + typeof panel.backgroundImage === "string" && + Array.isArray(panel.texts) && + typeof panel.height === "number" && + panel.height > 0 && + panel.height <= 1000 && + panel.createdAt instanceof Date && + panel.updatedAt instanceof Date + ); + } + + /** + * Получает количество сохраненных панелей + */ + getPanelCount(): number { + return this.getAllPanels().length; + } + + /** + * Проверяет, есть ли место для новой панели + */ + hasSpaceForNewPanel(): boolean { + return this.getPanelCount() < MAX_PANELS; + } +} + +export const panelStorage = new PanelStorage();