feat: start implement mvp

This commit is contained in:
2026-02-03 21:30:14 +05:00
parent 3f35247928
commit ac864e3791
10 changed files with 1132 additions and 2 deletions
+410
View File
@@ -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>
+213
View File
@@ -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);
}
});
}
}
+37
View File
@@ -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;
+45
View File
@@ -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;
}
+62
View File
@@ -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();
});
}
+97
View File
@@ -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,
};
}
+51
View File
@@ -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 });
};
+44
View File
@@ -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,
});
};