feat: start implement mvp
This commit is contained in:
Generated
+167
-1
@@ -8,7 +8,12 @@
|
|||||||
"name": "twitch-panels",
|
"name": "twitch-panels",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"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": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^7.0.0",
|
"@sveltejs/adapter-auto": "^7.0.0",
|
||||||
@@ -22,6 +27,126 @@
|
|||||||
"vite": "^7.3.1"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.2",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||||
@@ -991,6 +1116,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -1007,6 +1138,12 @@
|
|||||||
"undici-types": "~7.16.0"
|
"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": {
|
"node_modules/acorn": {
|
||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
@@ -1072,6 +1209,16 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/deepmerge": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1544,6 +1697,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
|
|||||||
+6
-1
@@ -23,6 +23,11 @@
|
|||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,410 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { uiStore } from "../stores/uiStore";
|
||||||
|
import { panelStore } from "../stores/panelStore";
|
||||||
|
import { ImageService } from "../lib/services/imageService";
|
||||||
|
import { handleError } from "../lib/utils/errorHandler";
|
||||||
|
import type { ImageUploadResult } from "../lib/types/panel";
|
||||||
|
|
||||||
|
let imageService = new ImageService();
|
||||||
|
|
||||||
|
let dropZone: HTMLElement;
|
||||||
|
let fileInput: HTMLInputElement;
|
||||||
|
let urlInput: HTMLInputElement;
|
||||||
|
let urlForm: HTMLFormElement;
|
||||||
|
|
||||||
|
let isDragOver = $state(false);
|
||||||
|
let showUrlInput = $state(false);
|
||||||
|
let uploadedImage = $state<string | null>(null);
|
||||||
|
let errorMessage = $state<string | null>(null);
|
||||||
|
|
||||||
|
$derived((uiError = $uiStore.error?.error));
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
setupPasteHandler();
|
||||||
|
setupDragAndDrop();
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupPasteHandler() {
|
||||||
|
document.addEventListener("paste", handlePaste);
|
||||||
|
return () => document.removeEventListener("paste", handlePaste);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupDragAndDrop() {
|
||||||
|
if (!dropZone) return;
|
||||||
|
|
||||||
|
dropZone.addEventListener("dragover", handleDragOver);
|
||||||
|
dropZone.addEventListener("dragleave", handleDragLeave);
|
||||||
|
dropZone.addEventListener("drop", handleDrop);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
dropZone.removeEventListener("dragover", handleDragOver);
|
||||||
|
dropZone.removeEventListener("dragleave", handleDragLeave);
|
||||||
|
dropZone.removeEventListener("drop", handleDrop);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
isDragOver = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
isDragOver = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
isDragOver = false;
|
||||||
|
|
||||||
|
const files = e.dataTransfer?.files;
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
|
const file = files[0];
|
||||||
|
await handleFileUpload(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileUpload(file: File) {
|
||||||
|
try {
|
||||||
|
uiStore.setLoading(true);
|
||||||
|
uiStore.clearError();
|
||||||
|
|
||||||
|
const result: ImageUploadResult = await imageService.handleFileUpload(file);
|
||||||
|
|
||||||
|
if (result.success && result.image) {
|
||||||
|
uploadedImage = result.image;
|
||||||
|
uiStore.setCurrentStep("crop");
|
||||||
|
} else {
|
||||||
|
errorMessage = result.error || "Ошибка загрузки файла";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage = handleError(error);
|
||||||
|
} finally {
|
||||||
|
uiStore.update((state) => ({ ...state, isLoading: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePaste(e: ClipboardEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
uiStore.setLoading(true);
|
||||||
|
uiStore.clearError();
|
||||||
|
|
||||||
|
const result: ImageUploadResult = await imageService.handlePasteUpload(e);
|
||||||
|
|
||||||
|
if (result.success && result.image) {
|
||||||
|
uploadedImage = result.image;
|
||||||
|
uiStore.setCurrentStep("crop");
|
||||||
|
} else {
|
||||||
|
errorMessage = result.error || "Ошибка вставки изображения";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage = handleError(error);
|
||||||
|
} finally {
|
||||||
|
uiStore.update((state) => ({ ...state, isLoading: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUrlSubmit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!urlInput.value.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
uiStore.update((state) => ({ ...state, isLoading: true }));
|
||||||
|
uiStore.update((state) => ({ ...state, error: null }));
|
||||||
|
|
||||||
|
const result: ImageUploadResult = await imageService.handleUrlUpload(urlInput.value.trim());
|
||||||
|
|
||||||
|
if (result.success && result.image) {
|
||||||
|
uploadedImage = result.image;
|
||||||
|
uiStore.update((state) => ({ ...state, currentStep: "crop" }));
|
||||||
|
showUrlInput = false;
|
||||||
|
} else {
|
||||||
|
errorMessage = result.error || "Ошибка загрузки изображения";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage = handleError(error);
|
||||||
|
} finally {
|
||||||
|
uiStore.update((state) => ({ ...state, isLoading: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerFileInput() {
|
||||||
|
fileInput?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetUpload() {
|
||||||
|
uploadedImage = null;
|
||||||
|
errorMessage = null;
|
||||||
|
showUrlInput = false;
|
||||||
|
uiStore.update((state) => ({ ...state, currentStep: "upload" }));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="image-upload-container">
|
||||||
|
{#if uploadedImage}
|
||||||
|
<div class="uploaded-image-preview">
|
||||||
|
<img src={uploadedImage} alt="Uploaded preview" />
|
||||||
|
<div class="preview-actions">
|
||||||
|
<button class="btn btn-secondary" on:click={resetUpload}> Загрузить другое </button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
bind:this={dropZone}
|
||||||
|
class="drop-zone {isDragOver ? 'drag-over' : ''} {uiError ? 'error' : ''}"
|
||||||
|
on:click={triggerFileInput}
|
||||||
|
>
|
||||||
|
<div class="drop-zone-content">
|
||||||
|
<svg class="upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
|
<polyline points="7 10 12 15 17 10"></polyline>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<h3>Загрузите изображение</h3>
|
||||||
|
<p>Перетащите файл сюда, нажмите для выбора, или вставьте через Ctrl+V</p>
|
||||||
|
|
||||||
|
<div class="upload-methods">
|
||||||
|
<button class="btn btn-primary" on:click={triggerFileInput}> Выбрать файл </button>
|
||||||
|
|
||||||
|
<button class="btn btn-secondary" on:click={() => (showUrlInput = !showUrlInput)}>
|
||||||
|
{showUrlInput ? "Скрыть" : "По URL"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showUrlInput}
|
||||||
|
<form bind:this={urlForm} on:submit={handleUrlSubmit} class="url-input-form">
|
||||||
|
<input
|
||||||
|
bind:this={urlInput}
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com/image.jpg"
|
||||||
|
required
|
||||||
|
class="url-input"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="btn btn-primary" disabled={$uiStore.isLoading}> Загрузить </button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="upload-tips">
|
||||||
|
<p>• Поддерживаемые форматы: JPG, PNG, WebP, GIF</p>
|
||||||
|
<p>• Максимальный размер: 10MB</p>
|
||||||
|
<p>• Или вставьте изображение через Ctrl+V</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
bind:this={fileInput}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/jpg,image/png,image/webp,image/gif"
|
||||||
|
style="display: none;"
|
||||||
|
on:change={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleFileUpload(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if errorMessage}
|
||||||
|
<div class="error-message">
|
||||||
|
<strong>Ошибка:</strong>
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if $uiStore.isLoading}
|
||||||
|
<div class="loading-overlay">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<p>Загрузка...</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.image-upload-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone {
|
||||||
|
border: 2px dashed #ccc;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
background: #f9f9f9;
|
||||||
|
min-height: 200px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone:hover {
|
||||||
|
border-color: #007bff;
|
||||||
|
background: #f0f8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone.drag-over {
|
||||||
|
border-color: #007bff;
|
||||||
|
background: #e3f2fd;
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone.error {
|
||||||
|
border-color: #dc3545;
|
||||||
|
background: #ffebee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone p {
|
||||||
|
margin: 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-methods {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-tips {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-tips p {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploaded-image-preview {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploaded-image-preview img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 300px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background: #ffebee;
|
||||||
|
color: #c62828;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 1rem;
|
||||||
|
border: 1px solid #ffcdd2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid #f3f3f3;
|
||||||
|
border-top: 4px solid #007bff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: #545b62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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<ImageUploadResult> {
|
||||||
|
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<ImageUploadResult> {
|
||||||
|
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<ImageUploadResult> {
|
||||||
|
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<ImageCropResult> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
maxRetries: number = 3,
|
||||||
|
delayMs: number = 1000,
|
||||||
|
): Promise<T> {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<HTMLImageElement> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<Panel | null> = 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>): 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["texts"][0]>): 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 });
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { writable, type Writable } from "svelte/store";
|
||||||
|
import { type UIState } from "../lib/types/panel";
|
||||||
|
|
||||||
|
export const uiStore: Writable<UIState> = 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,
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user