From ac864e3791fa0d707c414651321f96e6337558d9 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Tue, 3 Feb 2026 21:30:14 +0500 Subject: [PATCH] feat: start implement mvp --- package-lock.json | 168 +++++++++++- package.json | 7 +- src/components/ImageUpload.svelte | 410 ++++++++++++++++++++++++++++++ src/lib/services/imageService.ts | 213 ++++++++++++++++ src/lib/types/errors.ts | 37 +++ src/lib/types/panel.ts | 45 ++++ src/lib/utils/errorHandler.ts | 62 +++++ src/lib/utils/imageValidator.ts | 97 +++++++ src/stores/panelStore.ts | 51 ++++ src/stores/uiStore.ts | 44 ++++ 10 files changed, 1132 insertions(+), 2 deletions(-) create mode 100644 src/components/ImageUpload.svelte create mode 100644 src/lib/services/imageService.ts create mode 100644 src/lib/types/errors.ts create mode 100644 src/lib/types/panel.ts create mode 100644 src/lib/utils/errorHandler.ts create mode 100644 src/lib/utils/imageValidator.ts create mode 100644 src/stores/panelStore.ts create mode 100644 src/stores/uiStore.ts diff --git a/package-lock.json b/package-lock.json index 4f6945c..3cb9ca3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,12 @@ "name": "twitch-panels", "version": "0.0.1", "dependencies": { - "svelte-konva": "^1.0.1" + "@types/cropperjs": "^1.1.5", + "@types/uuid": "^10.0.0", + "cropperjs": "^2.1.0", + "file-saver": "^2.0.5", + "svelte-konva": "^1.0.1", + "uuid": "^13.0.0" }, "devDependencies": { "@sveltejs/adapter-auto": "^7.0.0", @@ -22,6 +27,126 @@ "vite": "^7.3.1" } }, + "node_modules/@cropper/element": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@cropper/element/-/element-2.1.0.tgz", + "integrity": "sha512-2zELddqHQNmlvkPoiYzE5nxEjPE+C8nXoTPuvV3FvLp3YjBinc7qb73Icg9UXP0o9qC4+h9q96JgGo0AyMO/Ng==", + "license": "MIT", + "dependencies": { + "@cropper/utils": "^2.1.0" + } + }, + "node_modules/@cropper/element-canvas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@cropper/element-canvas/-/element-canvas-2.1.0.tgz", + "integrity": "sha512-el+rfJpZxsD2q5XxDBA4fRczcrOqB65Lb7roqXOq8LKufwf4bPWA9C6DjNJJahh/TP94dsLIEy3tSkgRMDv3Aw==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.1.0", + "@cropper/utils": "^2.1.0" + } + }, + "node_modules/@cropper/element-crosshair": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@cropper/element-crosshair/-/element-crosshair-2.1.0.tgz", + "integrity": "sha512-0V589dAx8uZAfvJwdINLn76gfPQEafPH94ukjJ76uX0FCUovLaAVX+VRD/MDSYn0Mza/xejzmL9Dhd1DfemvmA==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.1.0", + "@cropper/utils": "^2.1.0" + } + }, + "node_modules/@cropper/element-grid": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@cropper/element-grid/-/element-grid-2.1.0.tgz", + "integrity": "sha512-dEnk0rO+vp553LMvsPYgfrqVFcYXeVFrgFeavBYYEhAXtO40p7kN4rmLYLMMjaN+T/Mx2BATv6kUQpALKy2HLw==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.1.0", + "@cropper/utils": "^2.1.0" + } + }, + "node_modules/@cropper/element-handle": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@cropper/element-handle/-/element-handle-2.1.0.tgz", + "integrity": "sha512-8BklWA4C/2GGAULupIWleSnGutECvYt3vx9flodqDfZpDEozws4LgLqmmzVuQmVkRVUdLnXdtx28kjgWLtzkHg==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.1.0", + "@cropper/utils": "^2.1.0" + } + }, + "node_modules/@cropper/element-image": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@cropper/element-image/-/element-image-2.1.0.tgz", + "integrity": "sha512-mXOV8ixJvG0XtTxLebYAKDjEkFbFOQnsF02hXPZk1yQSV0J+LLhN7a2NePrtKnoTsEV19fhhX3UorMoyGGxvzg==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.1.0", + "@cropper/element-canvas": "^2.1.0", + "@cropper/utils": "^2.1.0" + } + }, + "node_modules/@cropper/element-selection": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@cropper/element-selection/-/element-selection-2.1.0.tgz", + "integrity": "sha512-mtFtBl6HIa/s9TWohXw+Z5eJoeYTqylrIcHvS7oVv0uM7IyeRwBW65Q7z+KtLfq/LW+2Sw/XDyvR+VN/DawBPw==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.1.0", + "@cropper/element-canvas": "^2.1.0", + "@cropper/element-image": "^2.1.0", + "@cropper/utils": "^2.1.0" + } + }, + "node_modules/@cropper/element-shade": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@cropper/element-shade/-/element-shade-2.1.0.tgz", + "integrity": "sha512-zMdyqbb0lc0Vd1oj2Z1miIZvhyZG41OXMHvrNt0hNwblh0dVdrvtw48lnFDgRv+672vt2CNx7Q04GuvCQfPlgg==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.1.0", + "@cropper/element-canvas": "^2.1.0", + "@cropper/element-selection": "^2.1.0", + "@cropper/utils": "^2.1.0" + } + }, + "node_modules/@cropper/element-viewer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@cropper/element-viewer/-/element-viewer-2.1.0.tgz", + "integrity": "sha512-XnxlQuqHitd1FOFZ6E0yXAF5NYd/LyIvONLLHI9p1rJw747WYKUPxQaSYtFKF7IOizJu/8mMj++Zc1dZ5ZP3YQ==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.1.0", + "@cropper/element-canvas": "^2.1.0", + "@cropper/element-image": "^2.1.0", + "@cropper/element-selection": "^2.1.0", + "@cropper/utils": "^2.1.0" + } + }, + "node_modules/@cropper/elements": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@cropper/elements/-/elements-2.1.0.tgz", + "integrity": "sha512-qvzlYDn3VQgPPpsCu6Gi1XUO0v3vpXQFSjjxcVijbXeNsl/eiKrN7H9/CEiRgi5vr8kXfd7ZvgYxBjUBbH+y+w==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.1.0", + "@cropper/element-canvas": "^2.1.0", + "@cropper/element-crosshair": "^2.1.0", + "@cropper/element-grid": "^2.1.0", + "@cropper/element-handle": "^2.1.0", + "@cropper/element-image": "^2.1.0", + "@cropper/element-selection": "^2.1.0", + "@cropper/element-shade": "^2.1.0", + "@cropper/element-viewer": "^2.1.0" + } + }, + "node_modules/@cropper/utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@cropper/utils/-/utils-2.1.0.tgz", + "integrity": "sha512-wLtpZ4/UWgo+fGmG8NBWge8x5ehjfDe9ovleDfLy8kpwFaw43XXOEXQtRL1UNr0u4JZxaeO8FcXcolRWUUrlRQ==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -991,6 +1116,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/cropperjs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/cropperjs/-/cropperjs-1.1.5.tgz", + "integrity": "sha512-ofxLqZgZK6qtTjPedXfnph2nodHueejdNLtH/ljaNbM8rGRZg17YdBORJ/xYtKBdsonc7bIYKLGBfTGu/IQtcg==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1007,6 +1138,12 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1072,6 +1209,16 @@ "node": ">= 0.6" } }, + "node_modules/cropperjs": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-2.1.0.tgz", + "integrity": "sha512-SsSDqdVRl+mjbIBkGWlk1gCGcc+HzBqCbH5EQ+1tkAFUdxq2KUGukXF1RqhmvXrrdrX7PDwSUkWgXS7E36KvGQ==", + "license": "MIT", + "dependencies": { + "@cropper/elements": "^2.1.0", + "@cropper/utils": "^2.1.0" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -1163,6 +1310,12 @@ } } }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1544,6 +1697,19 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index cb702c9..c68b132 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,11 @@ "vite": "^7.3.1" }, "dependencies": { - "svelte-konva": "^1.0.1" + "@types/cropperjs": "^1.1.5", + "@types/uuid": "^10.0.0", + "cropperjs": "^2.1.0", + "file-saver": "^2.0.5", + "svelte-konva": "^1.0.1", + "uuid": "^13.0.0" } } diff --git a/src/components/ImageUpload.svelte b/src/components/ImageUpload.svelte new file mode 100644 index 0000000..c8d1fc7 --- /dev/null +++ b/src/components/ImageUpload.svelte @@ -0,0 +1,410 @@ + + +
+ {#if uploadedImage} +
+ Uploaded preview +
+ +
+
+ {:else} +
+
+ + + + + + +

Загрузите изображение

+

Перетащите файл сюда, нажмите для выбора, или вставьте через Ctrl+V

+ +
+ + + +
+ + {#if showUrlInput} +
+ + +
+ {/if} + +
+

• Поддерживаемые форматы: JPG, PNG, WebP, GIF

+

• Максимальный размер: 10MB

+

• Или вставьте изображение через Ctrl+V

+
+
+
+ + { + const file = e.target.files?.[0]; + if (file) handleFileUpload(file); + }} + /> + {/if} + + {#if errorMessage} +
+ Ошибка: + {errorMessage} +
+ {/if} + + {#if $uiStore.isLoading} +
+
+

Загрузка...

+
+ {/if} +
+ + diff --git a/src/lib/services/imageService.ts b/src/lib/services/imageService.ts new file mode 100644 index 0000000..21f682d --- /dev/null +++ b/src/lib/services/imageService.ts @@ -0,0 +1,213 @@ +import { type ImageUploadResult, type ImageCropResult } from "../types/panel"; +import { ImageError } from "../types/errors"; +import { + validateImageFile, + validateImageUrl, + loadImage, + getImageDimensions, + calculateAspectRatioFit, +} from "../utils/imageValidator"; +import { handleError, logError } from "../utils/errorHandler"; + +export class ImageService { + async handleFileUpload(file: File): Promise { + try { + // Validate file + const validation = validateImageFile(file); + if (!validation.isValid) { + return { + success: false, + error: validation.error, + }; + } + + // Convert file to base64 + const base64 = await this.fileToBase64(file); + + return { + success: true, + image: base64, + }; + } catch (error) { + logError(error, "File upload failed"); + return { + success: false, + error: handleError(error, "Ошибка загрузки файла"), + }; + } + } + + async handleUrlUpload(url: string): Promise { + try { + // Validate URL + const urlValidation = validateImageUrl(url); + if (!urlValidation.isValid) { + return { + success: false, + error: urlValidation.error, + }; + } + + // Load image + const img = await loadImage(url); + + // Validate loaded image + const imgValidation = getImageDimensions(img); + if (imgValidation.width === 0 || imgValidation.height === 0) { + return { + success: false, + error: "Не удалось загрузить изображение по указанному URL", + }; + } + + // Convert to base64 + const base64 = await this.imageToBase64(img); + + return { + success: true, + image: base64, + }; + } catch (error) { + logError(error, "URL upload failed"); + return { + success: false, + error: handleError(error, "Ошибка загрузки изображения по URL"), + }; + } + } + + async handlePasteUpload(pasteEvent: ClipboardEvent): Promise { + try { + const items = pasteEvent.clipboardData?.items; + if (!items || items.length === 0) { + return { + success: false, + error: "В буфер обмена не найдено изображений", + }; + } + + // Find image item + let imageItem: DataTransferItem | null = null; + for (let i = 0; i < items.length; i++) { + if (items[i].type.indexOf("image") !== -1) { + imageItem = items[i]; + break; + } + } + + if (!imageItem) { + return { + success: false, + error: "В буфер обмена не найдено изображений", + }; + } + + // Get file from clipboard item + const file = imageItem.getAsFile(); + if (!file) { + return { + success: false, + error: "Не удалось получить файл из буфера обмена", + }; + } + + // Validate and process file + const validation = validateImageFile(file); + if (!validation.isValid) { + return { + success: false, + error: validation.error, + }; + } + + const base64 = await this.fileToBase64(file); + + return { + success: true, + image: base64, + }; + } catch (error) { + logError(error, "Paste upload failed"); + return { + success: false, + error: handleError(error, "Ошибка вставки изображения"), + }; + } + } + + async cropImage( + imageSrc: string, + cropArea: { x: number; y: number; width: number; height: number }, + ): Promise { + try { + const img = await loadImage(imageSrc); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + if (!ctx) { + throw new ImageError("Не удалось создать контекст canvas"); + } + + // Set canvas size to crop area + canvas.width = cropArea.width; + canvas.height = cropArea.height; + + // Draw cropped image + ctx.drawImage( + img, + cropArea.x, + cropArea.y, + cropArea.width, + cropArea.height, + 0, + 0, + cropArea.width, + cropArea.height, + ); + + // Convert to base64 + const croppedImage = canvas.toDataURL("image/png"); + + return { + success: true, + croppedImage, + }; + } catch (error) { + logError(error, "Image cropping failed"); + return { + success: false, + error: handleError(error, "Ошибка обрезки изображения"), + }; + } + } + + private fileToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new ImageError("Ошибка чтения файла")); + reader.readAsDataURL(file); + }); + } + + private imageToBase64(img: HTMLImageElement): Promise { + return new Promise((resolve, reject) => { + try { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + if (!ctx) { + throw new ImageError("Не удалось создать контекст canvas"); + } + + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + + ctx.drawImage(img, 0, 0); + resolve(canvas.toDataURL("image/png")); + } catch (error) { + reject(error); + } + }); + } +} diff --git a/src/lib/types/errors.ts b/src/lib/types/errors.ts new file mode 100644 index 0000000..f29e2ef --- /dev/null +++ b/src/lib/types/errors.ts @@ -0,0 +1,37 @@ +export class AppError extends Error { + constructor( + message: string, + public code: string, + public recoverable: boolean = true, + public details?: unknown, + ) { + super(message); + this.name = "AppError"; + } +} + +export class ImageError extends AppError { + constructor(message: string, recoverable: boolean = true) { + super(message, "IMAGE_ERROR", recoverable); + } +} + +export class TextError extends AppError { + constructor(message: string, recoverable: boolean = true) { + super(message, "TEXT_ERROR", recoverable); + } +} + +export class CanvasError extends AppError { + constructor(message: string, recoverable: boolean = true) { + super(message, "CANVAS_ERROR", recoverable); + } +} + +export class StorageError extends AppError { + constructor(message: string, recoverable: boolean = true) { + super(message, "STORAGE_ERROR", recoverable); + } +} + +export type ErrorType = AppError | ImageError | TextError | CanvasError | StorageError; diff --git a/src/lib/types/panel.ts b/src/lib/types/panel.ts new file mode 100644 index 0000000..167be6e --- /dev/null +++ b/src/lib/types/panel.ts @@ -0,0 +1,45 @@ +export interface TextItem { + id: string; + text: string; + fontSize: number; + fontFamily: string; + color: string; + x: number; + y: number; +} + +export interface Panel { + id: string; + backgroundImage: string; + texts: TextItem[]; + height: number; + createdAt: Date; + updatedAt: Date; +} + +export interface ImageUploadResult { + success: boolean; + image?: string; + error?: string; +} + +export interface CropOptions { + width: number; + height: number; + x?: number; + y?: number; +} + +export interface ImageCropResult { + success: boolean; + croppedImage?: string; + error?: string; +} + +export interface UIState { + isLoading: boolean; + error: string | null; + currentStep: "upload" | "crop" | "text" | "preview"; + showCropModal: boolean; + showTextManager: boolean; +} diff --git a/src/lib/utils/errorHandler.ts b/src/lib/utils/errorHandler.ts new file mode 100644 index 0000000..465b9b6 --- /dev/null +++ b/src/lib/utils/errorHandler.ts @@ -0,0 +1,62 @@ +import { AppError, type ErrorType } from "../types/errors"; + +export function handleError(error: unknown, defaultMessage: string = "Произошла ошибка"): string { + if (error instanceof AppError) { + return error.recoverable ? `Ошибка: ${error.message}. Попробуйте снова.` : `Критическая ошибка: ${error.message}`; + } + + if (error instanceof Error) { + return `${defaultMessage}: ${error.message}`; + } + + if (typeof error === "string") { + return `${defaultMessage}: ${error}`; + } + + return `${defaultMessage}: Произошла неизвестная ошибка`; +} + +export function isRecoverableError(error: unknown): boolean { + if (error instanceof AppError) { + return error.recoverable; + } + return false; +} + +export function createError(message: string, code: string, recoverable: boolean = true, details?: unknown): AppError { + return new AppError(message, code, recoverable, details); +} + +export function logError(error: unknown, context?: string): void { + console.error("Error occurred:", { + error, + context, + timestamp: new Date().toISOString(), + stack: error instanceof Error ? error.stack : undefined, + }); +} + +export function retryOperation( + operation: () => Promise, + maxRetries: number = 3, + delayMs: number = 1000, +): Promise { + return new Promise((resolve, reject) => { + let attempt = 0; + + const attemptOperation = () => { + attempt++; + operation() + .then(resolve) + .catch((error) => { + if (attempt >= maxRetries) { + reject(error); + } else { + setTimeout(attemptOperation, delayMs * attempt); + } + }); + }; + + attemptOperation(); + }); +} diff --git a/src/lib/utils/imageValidator.ts b/src/lib/utils/imageValidator.ts new file mode 100644 index 0000000..d907295 --- /dev/null +++ b/src/lib/utils/imageValidator.ts @@ -0,0 +1,97 @@ +import { ImageError } from "../types/errors"; + +export const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB +export const SUPPORTED_FORMATS = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"]; + +export interface ValidationResult { + isValid: boolean; + error?: string; +} + +export function validateFileSize(file: File): ValidationResult { + if (file.size > MAX_FILE_SIZE) { + return { + isValid: false, + error: `Файл слишком большой. Максимальный размер: ${MAX_FILE_SIZE / 1024 / 1024}MB`, + }; + } + return { isValid: true }; +} + +export function validateFileType(file: File): ValidationResult { + if (!SUPPORTED_FORMATS.includes(file.type)) { + return { + isValid: false, + error: `Неподдерживаемый формат. Допустимые форматы: ${SUPPORTED_FORMATS.join(", ")}`, + }; + } + return { isValid: true }; +} + +export function validateImageFile(file: File): ValidationResult { + const sizeValidation = validateFileSize(file); + if (!sizeValidation.isValid) { + return sizeValidation; + } + + const typeValidation = validateFileType(file); + if (!typeValidation.isValid) { + return typeValidation; + } + + return { isValid: true }; +} + +export function validateImageUrl(url: string): ValidationResult { + try { + new URL(url); + return { isValid: true }; + } catch { + return { + isValid: false, + error: "Неверный URL формат", + }; + } +} + +export function validateImageElement(img: HTMLImageElement): ValidationResult { + if (!img.naturalWidth || !img.naturalHeight) { + return { + isValid: false, + error: "Изображение не может быть загружено или повреждено", + }; + } + return { isValid: true }; +} + +export async function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = "anonymous"; + + img.onload = () => resolve(img); + img.onerror = () => reject(new ImageError("Не удалось загрузить изображение")); + + img.src = src; + }); +} + +export function getImageDimensions(img: HTMLImageElement): { width: number; height: number } { + return { + width: img.naturalWidth, + height: img.naturalHeight, + }; +} + +export function calculateAspectRatioFit( + srcWidth: number, + srcHeight: number, + maxWidth: number, + maxHeight: number, +): { width: number; height: number } { + const ratio = Math.min(maxWidth / srcWidth, maxHeight / srcHeight); + return { + width: srcWidth * ratio, + height: srcHeight * ratio, + }; +} diff --git a/src/stores/panelStore.ts b/src/stores/panelStore.ts new file mode 100644 index 0000000..12bc60e --- /dev/null +++ b/src/stores/panelStore.ts @@ -0,0 +1,51 @@ +import { writable, type Writable } from "svelte/store"; +import { type Panel } from "../lib/types/panel"; +import { v4 as uuidv4 } from "uuid"; + +export const panelStore: Writable = writable(null); + +export const createEmptyPanel = (height: number = 100): Panel => { + return { + id: uuidv4(), + backgroundImage: "", + texts: [], + height, + createdAt: new Date(), + updatedAt: new Date(), + }; +}; + +export const updatePanel = (panel: Panel, updates: Partial): Panel => { + return { + ...panel, + ...updates, + updatedAt: new Date(), + }; +}; + +export const addTextToPanel = (panel: Panel, text: string): Panel => { + const newText = { + id: uuidv4(), + text, + fontSize: 18, + fontFamily: "Arial", + color: "#ffffff", + x: 160, // center horizontally + y: 50, // center vertically + }; + + return updatePanel(panel, { + texts: [...panel.texts, newText], + }); +}; + +export const updateTextInPanel = (panel: Panel, textId: string, updates: Partial): Panel => { + const updatedTexts = panel.texts.map((text) => (text.id === textId ? { ...text, ...updates } : text)); + + return updatePanel(panel, { texts: updatedTexts }); +}; + +export const removeTextFromPanel = (panel: Panel, textId: string): Panel => { + const updatedTexts = panel.texts.filter((text) => text.id !== textId); + return updatePanel(panel, { texts: updatedTexts }); +}; diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts new file mode 100644 index 0000000..d09b157 --- /dev/null +++ b/src/stores/uiStore.ts @@ -0,0 +1,44 @@ +import { writable, type Writable } from "svelte/store"; +import { type UIState } from "../lib/types/panel"; + +export const uiStore: Writable = writable({ + isLoading: false, + error: null, + currentStep: "upload", + showCropModal: false, + showTextManager: false, +}); + +export const setLoading = (loading: boolean): void => { + uiStore.update((state) => ({ ...state, isLoading: loading })); +}; + +export const setError = (error: string | null): void => { + uiStore.update((state) => ({ ...state, error })); +}; + +export const clearError = (): void => { + setError(null); +}; + +export const setCurrentStep = (step: UIState["currentStep"]): void => { + uiStore.update((state) => ({ ...state, currentStep: step })); +}; + +export const showCropModal = (show: boolean): void => { + uiStore.update((state) => ({ ...state, showCropModal: show })); +}; + +export const showTextManager = (show: boolean): void => { + uiStore.update((state) => ({ ...state, showTextManager: show })); +}; + +export const resetUI = (): void => { + uiStore.set({ + isLoading: false, + error: null, + currentStep: "upload", + showCropModal: false, + showTextManager: false, + }); +};