Merge pull request #1 from Ku6epXBOCTuK/feat/new-ui

Feat/new UI
This commit is contained in:
2026-02-21 11:57:24 +05:00
147 changed files with 14967 additions and 10205 deletions
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+65 -65
View File
@@ -1,65 +1,65 @@
name: Deploy SvelteKit
on:
push:
branches: [main, master]
paths:
- "src/**"
- "package.json"
- "tsconfig.json"
- "svelte.config.js"
- "vite.config.js"
- ".github/workflows/**"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Clean _app directory
run: |
rm -rf build/_app
touch build/.nojekyll
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: ./build
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
name: Deploy SvelteKit
on:
push:
branches: [main, master]
paths:
- "src/**"
- "package.json"
- "tsconfig.json"
- "svelte.config.js"
- "vite.config.js"
- ".github/workflows/**"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Clean _app directory
run: |
rm -rf build/_app
touch build/.nojekyll
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: ./build
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+2
View File
@@ -8,6 +8,7 @@ node_modules
/.svelte-kit
/build
/coverage
/.VSCodeCounter
# OS
.DS_Store
@@ -22,3 +23,4 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+9
View File
@@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/
+16
View File
@@ -0,0 +1,16 @@
{
"useTabs": false,
"singleQuote": false,
"trailingComma": "all",
"endOfLine": "lf",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"extends": ["stylelint-config-recommended", "stylelint-config-html/svelte"],
"rules": {
"selector-pseudo-class-no-unknown": [
true,
{
"ignorePseudoClasses": ["global"]
}
]
},
"ignoreFiles": [
".github/**/*",
".svelte-kit/**/*",
"build/**/*",
"coverage/**/*",
"dist/**/*",
"node_modules/**/*",
"reference/**/*"
]
}
-154
View File
@@ -1,154 +0,0 @@
# Component Organization Guide
This document describes the organized folder structure for the Svelte components in the Twitch Panels Creator project.
## 📁 Folder Structure
```
src/components/
├── layout/ # Application layout components
├── image/ # Image-related components
├── text/ # Text management components
├── panel/ # Panel display and management
├── feedback/ # User feedback components
└── ui/ # Reusable UI components (from lib)
```
## 🏗️ Layout Components
**Location**: `src/components/layout/`
Components that define the overall application structure and layout:
- **AppContainer.svelte** - Main application wrapper and coordinator
- **AppHeader.svelte** - Application header with title and actions
- **AppContent.svelte** - Main content area container
- **MainSection.svelte** - Primary workspace section
- **Sidebar.svelte** - Side panel for previews and controls
## 🖼️ Image Components
**Location**: `src/components/image/`
Components related to image upload, processing, and display:
- **ImageManager.svelte** - Orchestrates image upload and cropping workflow
- **ImageUpload.svelte** - Handles drag-drop, paste, and URL image upload
- **ImageCropper.svelte** - Provides image cropping interface with cropperjs
- **BackgroundPreview.svelte** - Displays the background image preview
## 📝 Text Components
**Location**: `src/components/text/`
Components for text management and display:
- **TextManager.svelte** - Main text input and settings management
- **TextSection.svelte** - Text management section wrapper
- **TextPreview.svelte** - Preview of text styling and positioning
## 🎨 Panel Components
**Location**: `src/components/panel/`
Components for panel creation, preview, and management:
- **PanelPreview.svelte** - Individual panel preview with canvas rendering
- **PanelsList.svelte** - List of all created panels
- **PanelList.svelte** - Alternative panel list implementation
## 💬 Feedback Components
**Location**: `src/components/feedback/`
Components for user feedback and error handling:
- **ErrorMessage.svelte** - Displays error messages to users
## 🧩 UI Components
**Location**: `src/components/ui/` (from lib)
Reusable UI components:
- **Button.svelte** - Standard button component
- **IconButton.svelte** - Button with icon support
## 🔧 Import Conventions
When importing components, follow these patterns:
```typescript
// Layout components
import AppContainer from "../components/layout/AppContainer.svelte";
import AppHeader from "../components/layout/AppHeader.svelte";
// Image components
import ImageManager from "../components/image/ImageManager.svelte";
import ImageUpload from "../components/image/ImageUpload.svelte";
// Text components
import TextManager from "../components/text/TextManager.svelte";
import TextSection from "../components/text/TextSection.svelte";
// Panel components
import PanelPreview from "../components/panel/PanelPreview.svelte";
import PanelsList from "../components/panel/PanelsList.svelte";
// Feedback components
import ErrorMessage from "../components/feedback/ErrorMessage.svelte";
// UI components
import { Button } from "../lib/components/ui";
```
## 🎯 Benefits of This Organization
1. **Logical Grouping**: Components are grouped by functionality, making them easy to find
2. **Maintainability**: Related components are co-located, reducing cognitive load
3. **Scalability**: Easy to add new components to appropriate categories
4. **Import Clarity**: Clear import paths that reflect component purpose
5. **Team Collaboration**: Consistent structure that team members can follow
## 📋 Component Responsibilities
### Layout Components
- Handle application-wide structure and navigation
- Manage high-level state and data flow
- Provide containers for functional components
### Image Components
- Handle all image-related operations (upload, crop, preview)
- Manage image state and validation
- Provide image processing functionality
### Text Components
- Manage text input, editing, and styling
- Handle text validation and state
- Provide text preview functionality
### Panel Components
- Handle panel creation and management
- Provide panel preview and export functionality
- Manage panel state and interactions
### Feedback Components
- Display user feedback and error messages
- Handle user notifications and alerts
- Provide consistent error handling UI
## 🔄 Future Considerations
As the project grows, consider:
1. **Sub-categorization**: Further divide categories if they become too large
2. **Feature-based organization**: Group by feature rather than type for larger applications
3. **Component documentation**: Add individual component documentation
4. **Storybook integration**: Use Storybook for component development and testing
This organization provides a solid foundation for the current project while remaining flexible for future expansion.
+100
View File
@@ -0,0 +1,100 @@
import { includeIgnoreFile } from "@eslint/compat";
import js from "@eslint/js";
import vitest from "@vitest/eslint-plugin";
import prettier from "eslint-config-prettier";
import svelte from "eslint-plugin-svelte";
import { defineConfig } from "eslint/config";
import globals from "globals";
import path from "node:path";
import ts from "typescript-eslint";
import svelteConfig from "./svelte.config.js";
const gitignorePath = path.resolve(import.meta.dirname, ".gitignore");
export default defineConfig(
{
ignores: ["reference/"],
},
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: { globals: { ...globals.browser, ...globals.node } },
rules: {
"no-undef": "off",
"no-unused-vars": "off",
"no-console": ["error", { allow: ["warn", "error"] }],
"@typescript-eslint/no-magic-numbers": [
"error",
{
enforceConst: true,
ignoreDefaultValues: true,
ignore: [0, 1, 2],
},
],
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
},
},
{
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: [".svelte"],
parser: ts.parser,
svelteConfig,
},
},
},
{
files: ["**/constants.ts", "**/constants/*.ts"],
rules: {
"no-magic-numbers": "off",
"@typescript-eslint/no-magic-numbers": "off",
},
},
{
files: ["**/constants.ts", "**/constants/*.ts"],
rules: {
"no-magic-numbers": "off",
"@typescript-eslint/no-magic-numbers": "off",
},
},
{
files: ["scripts/**"],
rules: {
"no-magic-numbers": "off",
"@typescript-eslint/no-magic-numbers": "off",
"no-console": "off",
},
},
{
files: ["**/*.test.ts", "**/*.spec.ts", "tests/**/*"],
plugins: { vitest },
rules: {
...vitest.configs.recommended.rules,
"vitest/no-focused-tests": "error",
"vitest/no-disabled-tests": "warn",
"vitest/expect-expect": "error",
"vitest/no-conditional-expect": "error",
"vitest/require-hook": "warn",
"vitest/consistent-test-it": ["error", { fn: "it", withinDescribe: "it" }],
"vitest/require-top-level-describe": "error",
"vitest/no-identical-title": "error",
"no-magic-numbers": "off",
"@typescript-eslint/no-magic-numbers": "off",
},
},
);
+7217 -3791
View File
File diff suppressed because it is too large Load Diff
+74 -50
View File
@@ -1,50 +1,74 @@
{
"name": "twitch-panels",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:unit": "vitest run tests/unit",
"test:integration": "vitest run tests/integration",
"test:e2e": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.58.1",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.1",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@types/file-saver": "^2.0.7",
"@types/node": "^25.1.0",
"@vitest/coverage-istanbul": "^4.0.18",
"@vitest/ui": "^4.0.18",
"jsdom": "^28.0.0",
"konva": "^10.2.0",
"svelte": "^5.48.2",
"svelte-check": "^4.3.5",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.0.18"
},
"dependencies": {
"@types/cropperjs": "^1.1.5",
"@types/uuid": "^10.0.0",
"cropperjs": "^2.1.0",
"file-saver": "^2.0.5",
"jszip": "^3.10.1",
"svelte-konva": "^1.0.1",
"uuid": "^13.0.0"
}
}
{
"name": "twitch-panels",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"lint:css": "stylelint '**/*.{css,svelte}",
"check:css": "node scripts/css-vars.js",
"check:all": "npm run check && npm run check:css && npm run lint:css && npm run lint && npm run test:run",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:unit": "vitest run tests/unit",
"test:integration": "vitest run tests/integration",
"test:e2e": "playwright test",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@eslint/compat": "^2.0.2",
"@eslint/js": "^9.39.2",
"@iconify-json/lucide": "^1.2.89",
"@playwright/test": "^1.58.1",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.1",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/file-saver": "^2.0.7",
"@types/node": "^20",
"@vitest/coverage-istanbul": "^4.0.18",
"@vitest/eslint-plugin": "^1.6.9",
"@vitest/ui": "^4.0.18",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.14.0",
"glob": "^13.0.2",
"globals": "^17.3.0",
"jsdom": "^28.0.0",
"konva": "^10.2.0",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1",
"stylelint": "^17.2.0",
"stylelint-config-html": "^1.1.0",
"stylelint-config-recommended": "^18.0.0",
"svelte": "^5.48.2",
"svelte-check": "^4.3.5",
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0",
"unplugin-icons": "^23.0.1",
"vite": "^7.3.1",
"vitest": "^4.0.18",
"vitest-canvas-mock": "^1.1.3",
"web-animations-js": "^2.3.2"
},
"dependencies": {
"@types/cropperjs": "^1.1.5",
"@types/uuid": "^10.0.0",
"cropperjs": "^2.1.0",
"file-saver": "^2.0.5",
"jszip": "^3.10.1",
"svelte-konva": "^1.0.1",
"uuid": "^13.0.0"
}
}
-226
View File
@@ -1,226 +0,0 @@
# Critical Issues Remediation Plan
## Phase 1: Testing Infrastructure (Priority 1)
**Timeline: Immediate**
### 1.1 Set Up Testing Framework
- Install Vitest for unit testing
- Install Playwright for E2E testing
- Install @testing-library/svelte for component testing
- Configure test scripts in package.json
### 1.2 Write Critical Tests
- Unit tests for error handling utilities
- Component tests for PanelPreview and core components
- E2E tests for panel creation and export flow
- Storage service tests with mocked localStorage
### 1.3 Test Coverage Requirements
- Minimum 80% code coverage
- Critical path coverage: 100%
- Error scenarios: 100% coverage
## Phase 2: Security Hardening (Priority 1)
**Timeline: Week 1**
### 2.1 Input Validation & Sanitization
```typescript
// Implement in panelStorage.ts
function validatePanelData(data: unknown): Panel {
// Schema validation using Zod or similar
// Sanitize text content to prevent XSS
// Validate image URLs and file types
// Size limits enforcement
}
```
### 2.2 Content Security Policy
- Implement CSP headers in SvelteKit configuration
- Restrict image sources to trusted domains
- Add XSS protection headers
### 2.3 Secure Storage Implementation
- Encrypt sensitive data in localStorage
- Implement data integrity checks
- Add versioning for backward compatibility
## Phase 3: Error Handling & UX (Priority 2)
**Timeline: Week 2**
### 3.1 Error Boundary Implementation
```svelte
<!-- ErrorBoundary.svelte -->
<script lang="ts">
import { onError } from 'svelte';
let error: Error | null = null;
onError((err) => {
error = err;
// Log to error reporting service
});
</script>
{#if error}
<ErrorFallback {error} onReset={() => error = null} />
{:else}
<slot />
{/if}
```
### 3.2 User-Friendly Error Messages
- Implement consistent error message formatting
- Add error recovery suggestions
- Create error message localization system
### 3.3 Loading States & Feedback
- Add skeleton loaders for image loading
- Implement progress indicators for exports
- Add toast notifications for user actions
## Phase 4: Performance Optimization (Priority 2)
**Timeline: Week 3**
### 4.1 Image Optimization
```typescript
// Implement image optimization service
class ImageOptimizationService {
async optimizeImage(file: File): Promise<Blob> {
// Compress images to optimal size
// Convert to WebP format when supported
// Implement responsive image sizing
}
lazyLoadImages(container: HTMLElement) {
// Intersection Observer implementation
}
}
```
### 4.2 Memory Management
- Implement proper cleanup in component lifecycle
- Add image caching with size limits
- Optimize Konva stage rendering
### 4.3 Bundle Optimization
- Implement code splitting
- Add tree shaking configuration
- Optimize vendor chunk splitting
## Phase 5: Accessibility Compliance (Priority 3)
**Timeline: Week 4**
### 5.1 WCAG 2.1 Compliance
- Add ARIA labels to all interactive elements
- Implement keyboard navigation
- Add screen reader announcements
- Ensure color contrast compliance
### 5.2 Responsive Design
- Test on multiple device sizes
- Implement touch-friendly interactions
- Add high contrast mode support
## Phase 6: Code Quality & Documentation (Priority 3)
**Timeline: Week 5**
### 6.1 Code Standardization
- Implement ESLint with Svelte-specific rules
- Add Prettier configuration
- Standardize error message language
- Add comprehensive JSDoc documentation
### 6.2 Monitoring & Analytics
- Implement error tracking (Sentry integration)
- Add performance monitoring
- Implement user analytics (privacy-compliant)
## Implementation Checklist
### Week 1: Foundation
- [ ] Set up Vitest testing framework
- [ ] Write core utility tests
- [ ] Implement input validation
- [ ] Add CSP headers
### Week 2: Reliability
- [ ] Implement error boundaries
- [ ] Add loading states
- [ ] Write component tests
- [ ] Implement error logging
### Week 3: Performance
- [ ] Optimize image handling
- [ ] Implement lazy loading
- [ ] Add memory management
- [ ] Optimize bundle size
### Week 4: Accessibility
- [ ] Add ARIA labels
- [ ] Implement keyboard navigation
- [ ] Test screen reader compatibility
- [ ] Add high contrast support
### Week 5: Polish
- [ ] Complete test coverage
- [ ] Add monitoring
- [ ] Final documentation
- [ ] Performance audit
## Success Metrics
- **Test Coverage**: >80% overall, 100% critical paths
- **Security**: Zero XSS vulnerabilities, CSP compliance
- **Performance**: <2s initial load, <100ms interactions
- **Accessibility**: WCAG 2.1 AA compliance
- **Error Rate**: <0.1% unhandled errors
## Risk Mitigation
### Technical Risks
- **Breaking Changes**: Implement feature flags for gradual rollout
- **Performance Regression**: Establish baseline metrics before changes
- **Browser Compatibility**: Maintain cross-browser testing
### Timeline Risks
- **Resource Constraints**: Prioritize Phase 1 and 2 items
- **Dependencies**: Use well-established libraries for security features
- **Testing Complexity**: Start with unit tests, expand to E2E gradually
## Next Steps
1. **Immediate**: Set up testing infrastructure
2. **This Week**: Implement security fixes
3. **Next Week**: Address critical error handling
4. **Ongoing**: Monitor and iterate based on user feedback
This remediation plan prioritizes the most critical issues that could impact production stability and user security. Each phase builds upon the previous one to ensure a robust, maintainable, and user-friendly application.
+330
View File
@@ -0,0 +1,330 @@
# Функциональный план (Product & UX Roadmap)
## Twitch Panels Creator
**Анализ версии:** 0.0.1
**Дата анализа:** 2025-02-09
**Технологии:** Svelte 5, TypeScript, Konva.js, Cropper.js
---
## 1. Текущие возможности (глазами пользователя)
### ✅ Реализованный функционал
#### 1.1 Управление текстами панелей
**Файлы:** [`src/states/texts.svelte.ts`](src/states/texts.svelte.ts), [`src/components/text/TextManager.svelte`](src/components/text/TextManager.svelte), [`src/components/text/TextConfig.svelte`](src/components/text/TextConfig.svelte)
- Добавление текстовых панелей через инпут + кнопку/Enter
- Редактирование текста инлайн (двойной клик или прямое редактирование)
- Удаление отдельных текстов
- Глобальные настройки текста (размер, шрифт, цвет, выравнивание, отступы, смещение)
- **Приоритет:** High - это основной рабочий поток
#### 1.2 Система превью панелей
**Файлы:** [`src/components/panel/PreviewManager.svelte`](src/components/panel/PreviewManager.svelte), [`src/components/panel/Preview.svelte`](src/components/panel/Preview.svelte), [`src/components/panel/PreviewAll.svelte`](src/components/panel/PreviewAll.svelte)
- Просмотр превью текущей панели с настройками текста
- Навигация между панелями (стрелки + индикатор "N / M")
- Просмотр всех панелей одновременно (в скрытом контейнере для экспорта)
- Плавные переходы между панелями (fly transition)
- **Приоритет:** High - критично для UX
#### 1.3 Экспорт панелей
**Файлы:** [`src/services/downloadService.ts`](src/services/downloadService.ts)
- Экспорт отдельной панели в PNG (320x100px)
- Массовый экспорт всех панелей в ZIP-архив
- Использование file-saver и JSZip
- **Приоритет:** High - финальная цель пользователя
#### 1.4 Система фоновых изображений (частичная)
**Файлы:** [`src/states/imageConfig.svelte.ts`](src/states/imageConfig.svelte.ts), [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte), [`src/components/image/CropInline.svelte`](src/components/image/CropInline.svelte)
- Загрузка изображения по URL (только UI кнопки)
- Визуальный интерфейс для обрезки (crop box с handles)
- Настройки яркости и контраста (только UI слайдеры)
- Автозагрузка дефолтного фона при старте
- **Приоритет:** Low - нерабочая система
#### 1.5 Базовый UI/UX
**Файлы:** [`src/components/layout/Card.svelte`](src/components/layout/Card.svelte), [`src/components/ui/Button.svelte`](src/components/ui/Button.svelte), [`src/components/ui/RangeSlider.svelte`](src/components/ui/RangeSlider.svelte), [`src/components/ui/ColorPicker.svelte`](src/components/ui/ColorPicker.svelte), [`src/components/ui/SelectFont.svelte`](src/components/ui/SelectFont.svelte), [`src/components/ui/Alignment.svelte`](src/components/ui/Alignment.svelte)
- Карточная система группировки
- Кнопки 5 типов (primary, secondary, outline, danger, mini)
- Слайдеры с отображением значения
- Цветовой пикер
- Выбор шрифта из 8 системных
- Три кнопки выравнивания
- Темная/светлая тема
- **Приоритет:** Medium - работает, но можно улучшить
---
## 2. Low-hanging fruit (Ближайшие улучшения)
### 2.1 Завершение системы изображений (High Priority)
**Проблема:** [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte) имеет UI, но нет логики загрузки/обработки.
**Требуемые правки:**
1. **Реализовать загрузку изображений** в [`src/services/imageService.ts`](src/services/imageService.ts):
- Drag & drop поддержка
- Paste из буфера обмена
- Загрузка по URL с валидацией
- Валидация форматов (JPEG, PNG, WebP, GIF) и размера (10MB max)
- **Контракт:** `imageService.uploadImage(source: File | string): Promise<ImageConfig>`
2. **Интегрировать cropperjs** в [`src/components/image/CropInline.svelte`](src/components/image/CropInline.svelte):
- Инициализация cropper.js на canvas
- Привязка crop box к данным состояния
- Реализовать drag & drop для перемещения
- Реализовать resize через 8 handles
- **Контракт:** `onCropChange(crop: { left, top, right, bottom })`
3. **Применить фильтры яркости/контраста**:
- В [`src/states/imageConfig.svelte.ts`](src/states/imageConfig.svelte.ts) добавить `brightness: number`, `contrast: number`
- Применить CSS filters к Konva Image в [`src/components/panel/Preview.svelte`](src/components/panel/Preview.svelte)
- **Контракт:** `filter: brightness(${brightness}%) contrast(${contrast}%)`
4. **Сохранить обрезанное изображение**:
- Метод `imageState.applyCrop()` должен обновлять `image` с обрезанными данными
- Использовать canvas для actual cropping
### 2.2 Валидация и состояния загрузки (Medium Priority)
**Тексты:**
- [`src/components/text/TextInput.svelte`](src/components/text/TextInput.svelte): добавить валидацию длины (max 100 chars из [`src/lib/constants.ts`](src/lib/constants.ts):32)
- Показать ошибку при превышении длины
- Добавить счетчик символов "3/100"
**Изображения:**
- [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte): показать состояние загрузки (spinner) при загрузке по URL
- Показать ошибку при неудачной загрузке
- Валидация формата/размера до загрузки
**Экспорт:**
- [`src/services/downloadService.ts`](src/services/downloadService.ts): показать прогресс-бар при `downloadAll()`
- Отключить кнопки во время экспорта
- Показать toast уведомление об успехе/ошибке
### 2.3 Пустые состояния (Medium Priority)
**Списки:**
- [`src/components/text/TextManager.svelte`](src/components/text/TextManager.svelte): когда `textsState.texts.length === 0`, показать красивый empty state вместо пустого UL
- [`src/components/panel/PreviewManager.svelte`](src/components/panel/PreviewManager.svelte): уже есть empty state, но можно улучшить (иконка + текст + CTA)
**Изображение:**
- [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte): когда нет изображения, показать placeholder с инструкцией
### 2.4 Улучшение доступности (Medium Priority)
**Файлы для правок:**
- Все кнопки уже имеют `aria-label`
- [`src/components/text/TextInput.svelte`](src/components/text/TextInput.svelte): добавить `aria-describedby` для валидации
- [`src/components/text/TextInlineEdit.svelte`](src/components/text/TextInlineEdit.svelte): добавить `aria-label` на delete button (уже есть)
- Добавить `role="region"` и `aria-label` на карточки
- Убедиться, что фокусный порядок логичен (Tab navigation)
---
## 3. Масштабирование (Крупные модули/интеграции)
### 3.1 Сохранение и загрузка проектов (High Priority)
**Проблема:** Нет persistence, пользователь теряет данные при перезагрузке.
**Решение:**
1. **Создать [`src/services/storageService.ts`](src/services/storageService.ts):**
- `saveProject(project: Project): Promise<void>` → localStorage
- `loadProject(id: string): Project | null`
- `listProjects(): ProjectInfo[]`
- `deleteProject(id: string)`
- **Project тип:** `{ id, name, createdAt, texts, imageConfig, textConfig }`
2. **Добавить UI для управления проектами:**
- [`src/components/layout/AppHeader.svelte`](src/components/layout/AppHeader.svelte): добавить кнопку "Проекты" → открывает модалку
- [`src/components/project/ProjectManager.svelte`](src/components/project/ProjectManager.svelte) (новый):
- Список сохраненных проектов
- Создание/переименование/удаление
- Экспорт/импорт JSON (для бэкапа)
3. **Автосохранение:**
- В [`src/routes/+layout.svelte`](src/routes/+layout.svelte): `$effect` на изменениях состояний → debounced save
- Восстановление при загрузке страницы
**User Retention impact:** Высокий - пользователи смогут возвращаться к своим работам.
### 3.2 Расширенные настройки текста (Medium Priority)
**Файлы для модификации:** [`src/states/textConfig.svelte.ts`](src/states/textConfig.svelte.ts), [`src/components/text/TextConfig.svelte`](src/components/text/TextConfig.svelte)
Добавить:
- **Тень текста:** `textShadow: { offsetX, offsetY, blur, color }`
- **Прозрачность:** `opacity: number` (0-100)
- **Градиент:** `gradient: { type: 'linear' | 'radial', colors: HexColor[], angle? }` (сложнее)
- **Несколько текстовых блоков на панели:** потребует переархитектуры Preview.svelte
**Приоритет:** Medium - улучшает качество панелей, но требует работы с Konva.
### 3.3 Расширенные настройки изображений (Medium Priority)
**Файлы:** [`src/states/imageConfig.svelte.ts`](src/states/imageConfig.svelte.ts), [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte)
Добавить:
- **Фильтры:** blur, sepia, saturate, grayscale
- **Наложение:** возможность добавить второй слой изображения
- **Режимы наложения:** multiply, screen, overlay (CSS blend modes)
**Приоритет:** Medium - улучшает креативность, но сложная реализация.
### 3.4 Пресеты и шаблоны (Low Priority)
**Новые файлы:**
- [`src/services/templateService.ts`](src/services/templateService.ts)
- [`src/components/template/TemplateGallery.svelte`](src/components/template/TemplateGallery.svelte)
**Функционал:**
- Предустановленные шаблоны (разные стили текста/изображений)
- Сохранение пользовательских пресетов
- Применение пресета к текущему проекту
**Приоритет:** Low - nice-to-have, не критично для MVP.
### 3.5 Интеграция с Twitch API (Future)
**Возможные интеграции:**
- Прямая загрузка панелей на Twitch через API
- Синхронизация с существующими панелями
- Планирование публикации
**Приоритет:** Low - требует OAuth, сложная интеграция.
---
## 4. User Retention (Повторное использование)
### 4.1 Сохранение проектов (см. 3.1) - HIGH
### 4.2 Настройки пользователя (Medium Priority)
**Файлы:** [`src/states/theme.svelte.ts`](src/states/theme.svelte.ts) уже есть, но нет сохранения.
**Добавить:**
- Сохранение темы в localStorage (частично есть в [`src/routes/+layout.svelte`](src/routes/+layout.svelte):15)
- Сохранение предпочитаемого шрифта
- Сохранение последних использованных цветов
- **Файл:** [`src/services/preferencesService.ts`](src/services/preferencesService.ts)
### 4.3 Экспорт в разные форматы (Medium Priority)
**Текущее состояние:** Только PNG.
**Добавить:**
- Экспорт в JPEG (с настройкой качества)
- Экспорт в WebP (современный формат)
- Экспорт в PDF (для печати)
- **Файл:** [`src/services/exportService.ts`](src/services/exportService.ts) (расширить downloadService)
**Приоритет:** Medium - увеличивает полезность.
### 4.4 История изменений (Low Priority)
**Новый файл:** [`src/services/historyService.ts`](src/services/historyService.ts)
**Функционал:**
- Undo/Redo для текстов и настроек
- Хранение N последних состояний
- Горячие клавиши Ctrl+Z / Ctrl+Y
**Приоритет:** Low - удобно, но не обязательно.
### 4.5 Совместная работа (Future)
**Сложная интеграция:**
- Share link с состоянием (закодировать в URL)
- Real-time collaboration (WebSocket)
- Комментарии/ревью
**Приоритет:** Very Low - далеко от MVP.
---
## 5. Приоритизация по фазам
### Фаза 1: Завершение базового функционала (2-3 недели)
**Цель:** Приложение должно работать end-to-end
1. Полная реализация системы изображений (2.1) - **High**
2. Сохранение проектов (3.1) - **High**
3. Валидация и состояния загрузки (2.2) - **Medium**
4. Пустые состояния (2.3) - **Medium**
5. Настройки пользователя (4.2) - **Medium**
**Критерий успеха:** Пользователь может создать, сохранить и загрузить проект с изображением и текстом.
### Фаза 2: Улучшение UX и расширение (3-4 недели)
1. Расширенные настройки текста (3.2) - **Medium**
2. Расширенные настройки изображений (3.3) - **Medium**
3. Экспорт в разные форматы (4.3) - **Medium**
4. Улучшение доступности (2.4) - **Medium**
5. Пресеты и шаблоны (3.4) - **Low**
**Критерий успеха:** Пользователь может создавать сложные панели с эффектами и быстро повторять стили.
### Фаза 3: Продвинутые фичи (4+ недели)
1. История изменений (4.4) - **Low**
2. Интеграция с Twitch API (3.5) - **Low**
3. Совместная работа (4.5) - **Very Low**
---
## 6. Метрики успеха
- **Активация:** >70% пользователей создают хотя бы 1 панель
- **Сохранение:** >30% пользователей сохраняют проект
- **Экспорт:** >50% пользователей экспортируют хотя бы 1 панель
- **Время на создание:** <2 минут для набора из 5 панелей
- **Retention week 1:** >40% возвращаются
---
## 7. Риски и ограничения
1. **Сложность Konva:** Ограниченная кастомизация текста (несколько блоков на панели потребует переписывания Preview.svelte)
2. **Производительность:** Множество Konva stages (konvaAllStages) могут тормозить при 50+ панелях
3. **Браузерные ограничения:** localStorage 5-10MB, может не хватить для изображений
4. **Cropper.js:** Требует доработки для работы с canvas/Konva
---
**Следующие шаги:**
1. Утвердить функциональный план
2. Перейти к техническому плану (ENGINEERING_IMPROVEMENTS.md)
3. Начать реализацию Фазы 1 (начиная с imageService)
-248
View File
@@ -1,248 +0,0 @@
# Twitch Panels Creator - Project Plan
## MVP (Minimum Viable Product)
### Цель MVP
Создать инструмент для быстрого создания нескольких Twitch панелей в едином стиле с разными текстами.
### Функциональность MVP
#### 1. Загрузка фонового изображения
- Автоматическая загрузка фонового изображения по умолчанию при старте
- Возможность загрузки собственного фонового изображения
- Обрезка изображения до нужных пропорций
#### 2. Создание панелей
- Ввод текстов для создания отдельных панелей
- Каждый текст создает отдельную панель
- Все панели используют одно фоновое изображение
- Настройки текста применяются ко всем панелям
#### 3. Настройки текста
- Размер шрифта (10-72px)
- Выбор шрифта из списка (Arial, Verdana, Georgia и др.)
- Цвет текста
- Выравнивание (лево, центр, право)
- Боковые отступы
- Вертикальное смещение от центра
#### 4. Управление текстами
- Список всех созданных текстов
- Редактирование текста прямо в списке
- Удаление текста кнопкой "×"
- Автоматическое обновление панелей при изменении текстов
- Автоматическая фильтрация пустых текстов и дубликатов
#### 5. Просмотр панелей
- Единое фоновое изображение в боковой панели (один раз вверху)
- Превью всех созданных панелей с текстом на фоне
- Превью только для просмотра
- Скачивание отдельной панели
- Массовое скачивание всех панелей
#### 6. Экспорт панелей
- Скачивание панелей в формате PNG
- Размер панелей: 320px ширина, настраиваемая высота (по умолчанию 100px)
## Workflow работы программы (MVP)
### Шаг 1: Запуск приложения
1. Пользователь открывает приложение
2. Автоматически загружается фоновое изображение по умолчанию
3. Пользователь видит экран добавления текстов
### Шаг 2: Настройка фона (опционально)
1. Фоновое изображение отображается в боковой панели с кнопкой "Загрузить"
2. Пользователь может загрузить собственное фоновое изображение
3. При необходимости обрезает изображение
4. Фон отображается один раз в боковой панели с правильными размерами (максимальная ширина 320px)
5. Фон применяется ко всем создаваемым панелям
6. Превью панелей показывают финальный результат с текстом на фоне
### Шаг 3: Создание и редактирование текстов
1. Пользователь вводит текст для первой панели (например: "links")
2. Нажимает кнопку "Добавить"
3. Текст добавляется в список текстов (пустые тексты и дубликаты автоматически фильтруются)
4. В боковой панели автоматически отображается превью созданной панели
5. Пользователь повторяет процесс для других текстов ("about me", "projects", "memes")
6. Все тексты отображаются в списке ниже поля ввода
7. Каждый текст в списке можно редактировать прямо в поле ввода (дубликаты автоматически фильтруются)
8. Любой текст можно удалить кнопкой "×"
9. При редактировании или удалении текста автоматически обновляется соответствующая панель
### Шаг 4: Настройка внешнего вида (опционально)
1. Пользователь настраивает параметры текста:
- Размер шрифта
- Шрифт
- Цвет
- Выравнивание
- Отступы
2. Настройки применяются ко всем создаваемым панелям
3. Превью панелей в боковой панели обновляется в реальном времени
### Шаг 5: Просмотр панелей
1. В боковой панели отображается фоновое изображение (один раз вверху)
2. Ниже отображаются превью всех созданных панелей
3. Превью панелей показывают финальный результат с текстом на фоне
4. Превью только для просмотра, редактирование происходит в списке текстов
### Шаг 6: Экспорт панелей
1. Пользователь скачивает отдельные панели или все сразу
2. Панели сохраняются в формате PNG
3. Готовые панели загружаются в Twitch
## Технические требования MVP
### Frontend
- ✅ Svelte 5 с runes
- ✅ TypeScript
- ✅ SvelteKit
- ✅ Vite для сборки
- ✅ Адаптер для статического деплоя
### Основные компоненты
#### UI компоненты (Реализованы)
- ✅ AppContainer - главный контейнер приложения
- ✅ AppHeader - заголовок приложения с кнопкой загрузки изображения
- ✅ AppContent - основной контент приложения
- ✅ ImageManager - управление загрузкой и обрезкой изображений
- ✅ TextSection - управление текстами и настройками
- ✅ BackgroundPreview - превью фонового изображения
- ✅ PanelsList - список превью всех созданных панелей
- ✅ Sidebar - боковая панель с превью
- ✅ MainSection - основная секция приложения
#### Вспомогательные компоненты (Реализованы)
- ✅ ImageUpload - загрузка изображений (drag-drop, paste, URL)
- ✅ ImageCropper - обрезка изображений с cropperjs
- ✅ TextManager - добавление, редактирование и удаление текстов
- ✅ PanelPreview - превью отдельной панели
- ✅ TextPreview - превью текста
- ✅ ErrorMessage - обработка ошибок
### Библиотеки и зависимости
- ✅ cropperjs v2.1.0 - обрезка изображений
- ✅ file-saver v2.0.5 - сохранение файлов
- ✅ jszip v3.10.1 - архивирование (для batch download)
- ✅ uuid - генерация ID
-@types/\* - TypeScript типы
### Хранилище данных (Реализовано)
- ✅ panelStore - хранение состояния панелей
- ✅ uiStore - хранение состояния UI (загрузка, ошибки)
- ✅ Локальное хранилище для сохранения панелей
### Сервисы (Реализованы)
- ✅ imageService - загрузка и обработка изображений
- ✅ panelService - управление панелями и текстами
- ✅ exportService - экспорт панелей в PNG
- ✅ Локальные и глобальные сервисы
### Архитектура
- ✅ Компонентная архитектура с разделением ответственности
- ✅ Сервисный слой для бизнес-логики
- ✅ Сторы для управления состоянием
- ✅ Типизация TypeScript
- ✅ Обработка ошибок и валидация
## Будущее развитие (Future Roadmap)
### Фаза 2: Расширение функциональности
#### 1. Дополнительные настройки
- Добавление иконок к панелям
- Настройка тени текста
- Градиентный текст
- Несколько текстовых элементов на одной панели
- Настройка прозрачности фона
#### 2. Библиотека шаблонов
- Предустановленные шаблоны панелей
- Возможность сохранения собственных шаблонов
- Категории шаблонов (игры, технологии, искусство и т.д.)
#### 3. Пакетная обработка
- Импорт списка текстов из файла
- Автоматическое создание панелей из списка
- Пакетное переименование панелей
### Фаза 3: Социальные функции
#### 1. Облачное хранилище
- Сохранение проектов в облаке
- Синхронизация между устройствами
- История версий
#### 2. Шаринг
- Возможность делиться проектами
- Публичная библиотека панелей
- Рейтинг и отзывы
## Метрики успеха MVP
- Время создания одной панели: < 30 секунд
- Время создания набора из 5 панелей: < 2 минут
- Успешное скачивание панелей: 100%
- Положительный фидбек от первых пользователей
## Ограничения MVP
- Один фоновый рисунок для всех панелей
- Фон отображается один раз в боковой панели (максимальная ширина 320px)
- Превью панелей показывают финальный результат с текстом на фоне
- Один текстовый элемент на панель
- Фиксированная ширина панели (320px)
- Только экспорт в PNG
- Работа в браузере без сохранения на сервере
- Редактирование текстов происходит в списке, превью только для просмотра
## Следующие шаги
### Фаза 1: Завершение MVP (Текущая)
1. ✅ Реализовать базовую структуру приложения
2. ✅ Реализовать загрузку и обрезку изображений
3. ✅ Реализовать создание панелей из текстов
4. ✅ Реализовать настройки текста
5. ✅ Реализовать управление текстами (редактирование, удаление)
6. ✅ Реализовать превью панелей (только для просмотра)
7. ✅ Реализовать экспорт панелей
8. ✅ Разделить редактирование текстов и превью панелей
9. 🔄 Реализовать пакетный экспорт (batch download) с JSZip
10. 🔄 Добавить валидацию и обработку ошибок
11. 🔄 Оптимизировать производительность рендеринга
12. ✅ Тестирование и исправление багов - ВСЕ ТЕСТЫ ПРОЙДЕНЫ (43 теста)
13. ⏳ Деплой на GitHub Pages
### Фаза 2: Расширенные функции (Будущая)
1. ⏳ Добавить иконки и продвинутые настройки текста
2. ⏳ Создать библиотеку шаблонов
3. ⏳ Реализовать пакетную обработку
4. ⏳ Добавить социальные функции и облачное хранилище
-227
View File
@@ -1,227 +0,0 @@
# Technical Debt Analysis: Twitch Panels Project
## Overview
This document provides a comprehensive analysis of technical debt in the Twitch Panels project, categorizing issues by severity and providing remediation strategies.
## Technical Debt Categories
### 🚨 **Critical Debt (Immediate Action Required)**
#### 1. Testing Infrastructure Debt
**Current State**: Zero testing infrastructure
**Debt Level**: Critical
**Impact**: High risk of regressions, unreliable deployments
**Files Affected**: Entire codebase
**Remediation Cost**: High (requires comprehensive test suite)
#### 2. Security Debt
**Current State**: No input validation, XSS vulnerabilities
**Debt Level**: Critical
**Impact**: Security breaches, data corruption
**Files Affected**:
- [`src/lib/utils/panelStorage.ts`](src/lib/utils/panelStorage.ts)
- [`src/services/exportService.ts`](src/services/exportService.ts)
- [`src/components/panel/PanelPreview.svelte`](src/components/panel/PanelPreview.svelte)
#### 3. Error Handling Debt
**Current State**: Inconsistent error handling, no error boundaries
**Debt Level**: Critical
**Impact**: Poor user experience, application crashes
**Files Affected**: All service files and components
### ⚠️ **Major Debt (High Priority)**
#### 4. Performance Debt
**Current State**: No optimization, memory leaks
**Debt Level**: High
**Impact**: Poor performance, user dissatisfaction
**Specific Issues**:
- Unoptimized image loading in [`PanelPreview.svelte`](src/components/panel/PanelPreview.svelte:36)
- No lazy loading implementation
- Missing cleanup in effect hooks
#### 5. Accessibility Debt
**Current State**: No accessibility features
**Debt Level**: High
**Impact**: Unusable for disabled users, legal compliance issues
**Files Affected**: All UI components
#### 6. Internationalization Debt
**Current State**: Mixed Russian/English error messages
**Debt Level**: High
**Impact**: Poor maintainability, inconsistent UX
**Files Affected**: [`src/lib/utils/errorHandler.ts`](src/lib/utils/errorHandler.ts:6-7)
### 🔧 **Minor Debt (Medium Priority)**
#### 7. Code Quality Debt
**Current State**: Inconsistent patterns, missing documentation
**Debt Level**: Medium
**Impact**: Reduced development velocity, onboarding difficulties
**Specific Issues**:
- Missing JSDoc comments
- Inconsistent file naming
- Mixed coding patterns
#### 8. Build Configuration Debt
**Current State**: Basic configuration, missing optimizations
**Debt Level**: Medium
**Impact**: Suboptimal build performance, larger bundle sizes
**Files Affected**: [`vite.config.ts`](vite.config.ts), [`svelte.config.js`](svelte.config.js)
#### 9. Dependency Management Debt
**Current State**: Some outdated dependencies
**Debt Level**: Low
**Impact**: Potential security vulnerabilities, missing features
**Files Affected**: [`package.json`](package.json)
## Debt Quantification
### Technical Debt Ratio
```
Technical Debt Ratio = (Remediation Time / Development Time) × 100
Estimated Ratio: 45%
```
### Debt Distribution
```
Critical: 40% (Testing, Security, Error Handling)
Major: 35% (Performance, Accessibility, i18n)
Minor: 25% (Code Quality, Build, Dependencies)
```
## Remediation Strategy
### Phase 1: Critical Debt (Weeks 1-2)
1. **Testing Infrastructure**
- Set up Vitest + Playwright
- Write core utility tests
- Implement component testing
- Target: 80% coverage
2. **Security Hardening**
- Implement input validation
- Add CSP headers
- Sanitize user inputs
- Encrypt sensitive data
3. **Error Handling**
- Add error boundaries
- Implement consistent error messages
- Add user feedback mechanisms
### Phase 2: Major Debt (Weeks 3-4)
1. **Performance Optimization**
- Implement image optimization
- Add lazy loading
- Optimize bundle size
- Fix memory leaks
2. **Accessibility Compliance**
- Add ARIA labels
- Implement keyboard navigation
- Test screen reader compatibility
- Ensure WCAG 2.1 compliance
3. **Internationalization**
- Standardize error messages
- Implement i18n framework
- Add language switching capability
### Phase 3: Minor Debt (Week 5)
1. **Code Quality**
- Add ESLint/Prettier
- Write comprehensive JSDoc
- Standardize code patterns
2. **Build Optimization**
- Optimize build configuration
- Add bundle analysis
- Implement code splitting
## Cost-Benefit Analysis
### Remediation Costs
- **Testing Infrastructure**: 40 hours
- **Security Hardening**: 24 hours
- **Error Handling**: 16 hours
- **Performance**: 32 hours
- **Accessibility**: 24 hours
- **Code Quality**: 16 hours
- **Total**: 152 hours (≈ 4 weeks)
### Benefits
- **Risk Reduction**: 90% reduction in security vulnerabilities
- **Quality Improvement**: 80% reduction in bug reports
- **Performance**: 50% improvement in load times
- **Accessibility**: WCAG 2.1 AA compliance
- **Maintainability**: 60% reduction in onboarding time
## Risk Assessment
### High-Risk Areas
1. **Production Deployment**: Current security vulnerabilities
2. **User Experience**: Accessibility violations
3. **Maintainability**: Zero test coverage
### Medium-Risk Areas
1. **Performance**: Unoptimized assets
2. **Scalability**: Memory management issues
3. **Reliability**: Inconsistent error handling
### Low-Risk Areas
1. **Build Process**: Basic but functional
2. **Dependencies**: Mostly up-to-date
3. **Code Style**: Inconsistent but readable
## Recommendations
### Immediate Actions (This Week)
1. **Stop Production Deployment**: Security vulnerabilities present
2. **Implement Basic Tests**: Start with critical utilities
3. **Add Input Validation**: Prevent XSS attacks
4. **Set Up Error Monitoring**: Track production errors
### Short-term Actions (Next 2 Weeks)
1. **Complete Test Suite**: Achieve 80% coverage
2. **Security Audit**: Comprehensive security review
3. **Performance Audit**: Identify optimization opportunities
4. **Accessibility Audit**: WCAG compliance assessment
### Long-term Actions (Next Month)
1. **Code Quality Standards**: Implement linting and formatting
2. **Performance Monitoring**: Continuous performance tracking
3. **Security Monitoring**: Automated vulnerability scanning
4. **Documentation**: Comprehensive API and architecture docs
## Conclusion
The project has significant technical debt, particularly in testing, security, and error handling. The estimated 45% technical debt ratio is above the recommended 25% threshold. Immediate action is required on critical debt items before production deployment.
The remediation effort of approximately 4 weeks will significantly improve code quality, security, and maintainability while reducing long-term maintenance costs.
File diff suppressed because it is too large Load Diff
-155
View File
@@ -1,155 +0,0 @@
# Статус проекта Twitch Panels - Полный обзор
## Дата обновления: 04.02.2026
## 📋 ОБЩЕЕ СОСТОЯНИЕ ПРИЛОЖЕНИЯ
Приложение для создания панелей Twitch полностью функционально и готово к использованию. ✅ Все тесты проходят успешно (43 теста).
## 🏗️ АРХИТЕКТУРА ПРИЛОЖЕНИЯ
### Основные модули:
**1. Компоненты пользовательского интерфейса (`src/components/`):**
- **Layout** - структура приложения (AppContainer, Sidebar, MainSection)
- **Panel** - работа с панелями (PanelPreview, PanelsList)
- **Image** - управление изображениями (ImageUpload, ImageCropper, ImageManager)
- **Text** - работа с текстом (TextManager, TextSection, TextPreview)
- **UI** - базовые элементы интерфейса (Button, IconButton)
- **Feedback** - обработка ошибок (ErrorMessage)
**2. Сервисы (`src/services/`):**
- **exportService** - экспорт готовых панелей (использует Konva Stage toBlob)
- **imageService** - загрузка и обработка изображений
- **panelService** - управление панелями и текстом
**3. Глобальное состояние (`src/stores/`):**
- **panelStore** - хранение данных о панелях
- **uiStore** - управление UI состоянием (текущий шаг, загрузка, ошибки)
**4. Типы и утилиты (`src/lib/`):**
- **types** - TypeScript типы для всего приложения
- **utils** - валидаторы изображений, обработка ошибок, хранение данных
**5. Маршрутизация (`src/routes/`):**
- **+page.svelte** - главная страница приложения
- **+layout.ts** - базовая настройка маршрутов
## ✅ ФУНКЦИОНАЛЬНОСТЬ
### Основные возможности:
1. **Создание панелей**
- Добавление текста с настройкой шрифтов, размера, цвета, выравнивания
- Настройка положения текста на панели
- Предпросмотр панелей в реальном времени
2. **Работа с изображениями**
- Загрузка изображений через файл, URL или буфер обмена
- Обрезка изображений с интерактивным интерфейсом
- Использование изображений в качестве фона панелей
3. **Экспорт панелей**
- Экспорт в формате PNG с высоким качеством
- Использование встроенного Konva Stage toBlob метода
- Пакетный экспорт нескольких панелей
### Текущий процесс работы:
1. Пользователь загружает фоновое изображение или использует стандартное
2. Добавляет текст с нужными настройками
3. Просматривает результат в реальном времени
4. Экспортирует готовые панели
## 📊 ТЕХНИЧЕСКИЕ ХАРАКТЕРИСТИКИ
**Технологии:**
- **Фреймворк:** Svelte 5 с современными runes
- **TypeScript:** Полная типизация всего приложения
- **Canvas:** Konva.js для работы с графикой
- **Обработка изображений:** Cropper.js для обрезки
- **Сборка:** Vite для быстрой разработки
**Производительность:**
- ✅ Все TypeScript проверки проходят без ошибок
- ✅ Современная реактивная система Svelte 5
- ✅ Оптимизированная работа с изображениями
- ✅ Чистая архитектура без дублирования
- ✅ ✅ Полная тестовая покрытие критичных компонентов (43 теста)
## 🧪 ТЕСТИРОВАНИЕ
**Реализованные тесты:**
-**Обработчик ошибок** - 19 тестов покрывают все сценарии обработки ошибок
-**Хранилище панелей** - 24 теста покрывают сохранение, загрузку, удаление и валидацию данных
-**Инфраструктура тестирования** - Vitest, покрытие кода, моки
**Команды для запуска тестов:**
- `npm test` - запуск тестов в режиме наблюдения
- `npm run test:run` - одноразовый запуск всех тестов
- `npm run test:coverage` - запуск тестов с отчетом о покрытии
## 🎯 ДЛЯ ПОЛЬЗОВАТЕЛЯ
**Что может делать пользователь:**
- Создавать неограниченное количество панелей
- Настраивать текст (шрифт, размер, цвет, выравнивание)
- Загружать собственные фоновые изображения
- Обрезать изображения до нужного размера
- Предварительно просматривать панели перед экспортом
- Экспортировать панели в высоком качестве для Twitch
**Процесс использования:**
1. Открыть приложение
2. Загрузить фоновое изображение (по желанию)
3. Добавить текст с нужными настройками
4. Просмотреть результат
5. Экспортировать панель
6. Использовать экспортированную панель в Twitch
## 📁 ФАЙЛОВАЯ СТРУКТУРА
```
src/
├── components/ # UI компоненты
│ ├── layout/ # Структура приложения
│ ├── panel/ # Компоненты панелей
│ ├── image/ # Компоненты работы с изображениями
│ ├── text/ # Компоненты работы с текстом
│ ├── ui/ # Базовые UI элементы
│ └── feedback/ # Обработка ошибок
├── services/ # Бизнес-логика
│ ├── exportService.ts # Экспорт (Konva Stage toBlob)
│ ├── imageService.ts # Работа с изображениями
│ └── panelService.ts # Управление панелями
├── stores/ # Глобальное состояние
│ ├── panelStore.ts # Данные о панелях
│ └── uiStore.ts # UI состояние
├── lib/ # Библиотечный код
│ ├── types/ # TypeScript типы
│ └── utils/ # Утилиты и валидаторы
├── routes/ # Страницы приложения
└── app.html # HTML шаблон
```
## 🚀 СТАТУС РАЗРАБОТКИ
**✅ Готово к использованию:**
- Полностью функциональное приложение
- Все основные фичи реализованы
- Прошло все TypeScript проверки
- Оптимизирована архитектура
**Текущая версия:** Готова к деплою и использованию пользователями Twitch.
-410
View File
@@ -1,410 +0,0 @@
# Core Features Implementation Plan
## 🎯 **Focus: Core Features First**
This plan prioritizes the essential features needed for a functional Twitch panel creator, deferring advanced features like batch download for later implementation.
### **Phase 1: Minimum Viable Product (MVP)**
#### **1. Image Upload System** ✅ **COMPLETED**
**Priority: HIGH**
- **Drag & Drop Zone**: Visual feedback, file validation ✅
- **Ctrl+V Paste**: Clipboard API integration ✅
- **URL Input**: External image loading ✅
- **Image Preview**: Before cropping confirmation ✅
**Key Components:**
```typescript
interface ImageUploadProps {
onImageSelect: (image: string) => void;
onError: (error: string) => void;
}
// Features:
- File type validation (jpg, png, webp)
- Size limit (10MB max)
- Drag over visual states
- Paste detection
- URL fetch with CORS handling
```
#### **2. Image Cropping Interface** ✅ **COMPLETED**
**Priority: HIGH**
- **Cropper Integration**: Fixed 320px width constraint ✅
- **Crop Confirmation**: Accept/Cancel options ✅
- **Error Handling**: Invalid crop areas ✅
**Implementation Details:**
- Integrated cropperjs v2.1.0 with Web Components
- Used `$toCanvas()` method to get HTMLCanvasElement
- Implemented proper error handling with user-friendly messages
**Key Components:**
```typescript
interface ImageCropperProps {
image: string;
onCropComplete: (croppedImage: string) => void;
onCancel: () => void;
}
// Features:
- Fixed width (320px), variable height
- Aspect ratio locking
- Crop area validation
- Base64 output
- Mobile responsive
```
#### **3. Text Management System** ✅ **COMPLETED**
**Priority: HIGH**
- **Dynamic Text List**: Add, edit, delete ✅
- **Text Styling**: Font, size, color, positioning ✅
- **Real-time Updates**: Live preview sync ✅
- **Common Settings**: Apply settings to all texts ✅
**Key Components:**
```typescript
interface TextItem {
id: string;
text: string;
fontSize: number;
fontFamily: string;
color: string;
x: number;
y: number;
}
interface TextManagerProps {
texts: TextItem[];
onTextChange: (texts: TextItem[]) => void;
}
// Features:
- Add new text item
- Edit existing text
- Delete text item
- Font selection from available fonts
- Font size adjustment
- Color picker
- Position controls (x, y)
- Text validation (length limits)
```
#### **4. Canvas Rendering Engine** ✅ **COMPLETED**
**Priority: HIGH**
- **SvelteKonva Integration**: Enhanced implementation ✅
- **Dynamic Height**: Configurable panel height ✅
- **Real-time Preview**: Live updates ✅
- **Layer Management**: Background + text layers ✅
**Key Components:**
```typescript
interface PanelCanvasProps {
backgroundImage: string;
texts: TextItem[];
width: number; // 320px fixed
height: number; // configurable
}
// Features:
- Background image layer
- Text layers with proper positioning
- Dynamic height support
- Real-time rendering
- Performance optimization
```
#### **5. Basic Panel Management** ✅ **COMPLETED**
**Priority: MEDIUM**
- **Panel Storage**: Local storage for current panel ✅
- **Panel Navigation**: Basic next/prev (single panel for MVP) ✅
- **Panel Validation**: Basic validation ✅
- **Panel List**: View and manage saved panels ✅
**Key Components:**
```typescript
interface Panel {
id: string;
backgroundImage: string;
texts: TextItem[];
height: number;
createdAt: Date;
}
interface PanelManagerProps {
currentPanel: Panel | undefined;
onPanelUpdate: (panel: Panel) => void;
}
// Features:
- Save current panel state
- Load panel from storage
- Basic validation
- Single panel focus for MVP
```
### **Phase 2: Enhanced Core Features**
#### **6. User Interface Enhancements** ✅ **COMPLETED**
**Priority: MEDIUM**
-**Responsive Layout**: Mobile-friendly design
-**Loading States**: Visual feedback через uiStore
-**Error Messages**: User-friendly error handling
-**Keyboard Shortcuts**: Ctrl+V для вставки изображений
#### **7. Error Handling & Validation** ✅ **COMPLETED**
**Priority: MEDIUM**
-**Input Validation**: Валидация текста и изображений
-**Error Boundaries**: Компонент ErrorMessage для обработки ошибок
-**User Guidance**: Понятные сообщения об ошибках
-**Retry Mechanisms**: Возможность повторной загрузки
#### **8. Batch Download System** 🔄 **IN PROGRESS**
**Priority: HIGH**
- 🔄 **JSZip Integration**: Подключена библиотека JSZip
-**Batch Rendering**: Параллельная генерация изображений
-**Progress Tracking**: Индикатор прогресса экспорта
-**ZIP Archive Creation**: Создание архива с панелями
### **Implementation Order** ✅ **UPDATED STATUS**
```
Week 1-2: Foundation ✅ COMPLETED
├── Setup dependencies (cropperjs, file-saver, jszip) ✅
├── TypeScript types and interfaces ✅
├── Error handling structure ✅
├── Basic project structure ✅
└── SvelteKit configuration ✅
Week 2-3: Image System ✅ COMPLETED
├── Image upload component ✅
├── Image cropping component ✅
├── Image validation utilities ✅
├── Image service layer ✅
└── Default backgrounds loading ✅
Week 3-4: Text System ✅ COMPLETED
├── Text management component ✅
├── Text styling controls ✅
├── Common text settings ✅
├── Text validation ✅
└── Text service layer ✅
Week 4-5: Canvas & Integration ✅ COMPLETED
├── Enhanced canvas implementation ✅
├── Dynamic height support ✅
├── Real-time preview ✅
├── Panel storage system ✅
└── Panel management ✅
Week 5-6: UI & Polish ✅ COMPLETED
├── Responsive layout ✅
├── Loading states ✅
├── Error handling improvements ✅
├── Keyboard shortcuts ✅
└── User experience polish ✅
Week 6-7: Batch Download 🔄 IN PROGRESS
├── JSZip integration ✅
├── Batch rendering engine ⏳
├── Progress tracking ⏳
└── ZIP archive creation ⏳
Week 7-8: Testing & Deployment ⏳ PLANNED
├── Unit testing ⏳
├── Integration testing ⏳
├── Bug fixes ⏳
└── GitHub Pages deployment ⏳
```
## 🔧 **Technical Specifications**
### **Dependencies to Install**
```bash
npm install cropperjs@^2.1.0 file-saver @types/cropperjs@^1.1.5
```
**Note:** Using cropperjs v2.1.0 with Web Components API. The `$toCanvas()` method is used to obtain HTMLCanvasElement from `<cropper-canvas>` component.
### **TypeScript Interfaces**
```typescript
// Core types
interface TextItem {
id: string;
text: string;
fontSize: number;
fontFamily: string;
color: string;
x: number;
y: number;
}
interface Panel {
id: string;
backgroundImage: string;
texts: TextItem[];
height: number;
createdAt: Date;
}
interface ImageUploadResult {
success: boolean;
image?: string;
error?: string;
}
```
### **State Management**
```typescript
// Core stores
export const panelStore = writable<Panel | undefined>(undefined);
export const uiStore = writable({
isLoading: false,
error: string | undefined,
currentStep: "upload" | "crop" | "text" | "preview",
});
```
### **Error Handling Strategy**
```typescript
// Error types
export class AppError extends Error {
constructor(
message: string,
public code: string,
public recoverable: boolean = true,
) {
super(message);
}
}
// Error handling utilities
export const handleImageError = (error: unknown): string => {
if (error instanceof AppError) {
return error.recoverable ? `Ошибка: ${error.message}. Попробуйте снова.` : `Критическая ошибка: ${error.message}`;
}
return "Произошла неизвестная ошибка";
};
```
## 🚨 **Common Issues & Solutions**
### **Image Upload Issues**
1. **CORS Errors**
- Solution: Use proxy for external images or allow CORS in development
- Fallback: Show error message with alternative upload methods
2. **Large Files**
- Solution: Implement client-side compression
- Limit: 10MB per file with clear user feedback
3. **Invalid Formats**
- Solution: Validate before upload, show supported formats
- Fallback: Convert to webp if possible
### **Canvas Rendering Issues**
1. **Memory Limits**
- Solution: Implement lazy rendering and cleanup
- Monitor: Canvas size and memory usage
2. **Font Loading**
- Solution: Fallback to web-safe fonts
- Preload: Load fonts during initialization
3. **Performance**
- Solution: Debounce rapid updates
- Optimize: Use requestAnimationFrame for smooth rendering
### **User Experience Issues**
1. **Slow Operations**
- Solution: Loading states and progress indicators
- Optimize: Async operations with proper error handling
2. **Complex Interface**
- Solution: Step-by-step wizard approach
- Guide: Tooltips and help text
## 📱 **UI/UX Considerations**
### **Mobile First Design**
- Touch-friendly controls
- Responsive layout for all screen sizes
- Swipe gestures for navigation
### **Accessibility**
- Screen reader compatibility
- Keyboard navigation
- High contrast mode support
### **Performance**
- Lazy loading of components
- Optimized bundle size
- Efficient state management
---
_Created: 2026-02-03_
_Focus: Core Features Implementation_
_Estimated Duration: 5 weeks_
## 📝 **Implementation Notes**
### **Image Cropping Implementation (Completed)**
**Challenge:** cropperjs v2.x uses Web Components API which differs significantly from v1.x
**Solution:**
1. Used `getCropperCanvas()` to get `<cropper-canvas>` Web Component
2. Called `$toCanvas()` method to convert to HTMLCanvasElement
3. Applied `toDataURL()` on the resulting canvas to get base64 image
**Code Example:**
```typescript
const cropperCanvasElement = cropper.getCropperCanvas();
const canvas = await cropperCanvasElement.$toCanvas({
width: 320,
imageSmoothingEnabled: true,
imageSmoothingQuality: "high",
});
const croppedImage = canvas.toDataURL("image/png");
```
**Key Points:**
- cropperjs v2.x methods return Web Components, not standard DOM elements
- `$toCanvas()` is an async method that returns HTMLCanvasElement
- Proper error handling is essential for user experience
-312
View File
@@ -1,312 +0,0 @@
# Twitch Panels Implementation Plan
## 🎯 **Project Overview**
A SvelteKit-based Twitch panel creator with the following key features:
- **320px width** panels with **configurable height** (default 100px)
- **Multiple image upload methods**: URL, drag-and-drop, Ctrl+V paste
- **Image cropping interface** with proper constraints
- **Dynamic text management** (add, edit, delete)
- **Real-time preview** with navigation
- **Batch download** using JSZip
## 📁 **Project Structure**
```
src/
├── components/
│ ├── ImageUpload.svelte # Drag/drop, paste, URL input
│ ├── ImageCropper.svelte # Cropperjs integration
│ ├── TextManager.svelte # Text CRUD operations
│ ├── PanelPreview.svelte # Canvas preview
│ ├── PanelList.svelte # Panel navigation
│ └── DownloadModal.svelte # Batch download
├── lib/
│ ├── utils/
│ │ ├── imageProcessor.ts # Image handling
│ │ ├── textManager.ts # Text operations
│ │ ├── panelStorage.ts # Local storage
│ │ └── batchProcessor.ts # JSZip integration
│ ├── types/
│ │ ├── panel.ts # TypeScript interfaces
│ │ └── errors.ts # Error types
│ └── services/
│ ├── imageService.ts # Image operations
│ ├── textService.ts # Text operations
│ └── exportService.ts # Export functionality
└── stores/
├── panelStore.ts # Reactive state
└── uiStore.ts # UI state
```
## 📋 **Implementation Tasks**
### **Setup and Configuration**
- [x] Install required dependencies (JSZip, cropperjs, file-saver)
- [x] Configure TypeScript types for new features
- [x] Set up proper error handling structure
- [x] Configure SvelteKit with static adapter
- [x] Set up GitHub Pages deployment configuration
### **Core Image Upload System**
- [x] Create drag-and-drop zone component
- [x] Implement Ctrl+V paste functionality
- [x] Add URL-based image loading
- [x] Create image validation and error handling
- [x] Implement image preview before cropping
- [x] Add default background images loading
### **Image Cropping Interface**
- [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**
- [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
- [x] Add common text settings for all text items
- [x] Implement text alignment and padding controls
### **Canvas Rendering Engine**
- [x] Upgrade canvas implementation with native Canvas API
- [x] Create dynamic height support
- [x] Implement real-time preview updates
- [x] Add layer management system
- [x] Optimize rendering performance
- [x] Add text positioning and styling
### **Panel Management**
- [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
- [x] Add panel list component with preview
- [x] Implement panel creation from texts
### **Batch Download System**
- [x] Integrate JSZip library
- [ ] Create batch rendering engine
- [ ] Implement parallel image generation
- [ ] Add progress tracking
- [ ] Implement ZIP archive creation
- [ ] Add batch download UI controls
- [x] Handle large batch downloads (basic implementation)
- [ ] Create download error recovery
### **User Interface** ✅ **COMPLETED**
- [x] Design responsive layout
- [x] Create modal dialogs for crop/confirm
- [x] Add loading states and spinners
- [x] Implement keyboard shortcuts
- [x] Add tooltips and help text (through error messages)
- [x] Create error message system
### **Error Handling and Validation** ✅ **COMPLETED**
- [x] Implement comprehensive error boundaries
- [x] Add input validation for all forms
- [x] Create user-friendly error messages
- [x] Add retry mechanisms for failed operations
- [x] Implement logging for debugging
### **Performance Optimization** 🔄 **IN PROGRESS**
- [x] Add image compression for uploads (basic)
- [x] Implement lazy loading for panels
- [x] Optimize canvas rendering
- [ ] Add memory management for large batches
- [x] Create debouncing for rapid inputs
### **Testing and Quality Assurance** ⏳ **PLANNED**
- [ ] Create unit tests for core functions
- [ ] Add integration tests for user flows
- [ ] Test with various image formats and sizes
- [ ] Validate error scenarios
- [ ] Test performance with large datasets
### **Documentation and Deployment** ⏳ **PLANNED**
- [ ] Create user documentation
- [ ] Add GitHub Pages deployment script
- [ ] Configure proper build optimization
- [ ] Set up CI/CD pipeline
- [ ] Set up CI/CD pipeline
- [ ] Create README with setup instructions
## 🔧 **Technical Implementation Details**
### **Core Components Architecture**
#### **Image Upload System**
- **Drag & Drop**: HTML5 drag API with visual feedback
- **Paste Handler**: Clipboard API integration
- **URL Input**: Fetch API with CORS handling
- **Validation**: File size limits (max 10MB), format checking
#### **Image Cropping**
- **Library**: Cropper.js with Svelte wrapper
- **Constraints**: Fixed 320px width, variable height
- **Output**: Base64 cropped images
- **Error Handling**: Invalid crop areas, format conversion
#### **Text Management**
- **CRUD Operations**: Add, edit, delete text elements
- **Styling**: Font selection, size, color, positioning
- **Validation**: Text length limits, character encoding
- **Real-time Updates**: Live preview synchronization
#### **Canvas Rendering**
- **SvelteKonva**: Enhanced implementation
- **Dynamic Height**: Flexible panel dimensions
- **Layer Management**: Background + text layers
- **Performance**: Debounced updates, lazy rendering
#### **Batch Processing**
- **JSZip**: Parallel image generation
- **Progress Tracking**: Real-time progress updates
- **Memory Management**: Stream processing for large batches
- **Error Recovery**: Failed image retry mechanism
## 🚨 **Error Handling Strategy**
### **Common Issues to Address**
1. **Image Loading Failures**
- CORS errors for external URLs
- Corrupted image files
- Network timeouts
2. **Canvas Rendering Issues**
- Memory limits for large canvases
- Font loading failures
- Invalid text encoding
3. **User Input Errors**
- Empty text fields
- Invalid image URLs
- Crop area too small
4. **Export Problems**
- Large batch timeouts
- Storage quota exceeded
- Browser download restrictions
### **Error Recovery Patterns**
- **Retry Mechanisms**: Exponential backoff for failed operations
- **Fallback Options**: Default fonts, error images
- **User Guidance**: Clear error messages with suggested fixes
- **Graceful Degradation**: Basic functionality without advanced features
## 📱 **User Interface Considerations**
### **Responsive Design**
- **Mobile Support**: Touch-friendly controls
- **Desktop Optimization**: Keyboard shortcuts
- **Accessibility**: Screen reader compatibility
### **User Experience**
- **Loading States**: Visual feedback during operations
- **Progress Indicators**: For batch processes
- **Undo/Redo**: For text and panel operations
- **Keyboard Shortcuts**: Ctrl+V for paste, arrow keys for navigation
## 🚀 **Deployment Configuration**
### **GitHub Pages Setup**
- **Base Path**: Automatic path handling for subdirectories
- **Build Optimization**: Code splitting, lazy loading
- **Cache Strategy**: Proper cache headers for static assets
### **Performance Optimizations**
- **Image Compression**: WebP format conversion
- **Lazy Loading**: On-demand panel rendering
- **Bundle Splitting**: Separate chunks for large libraries
- **Service Worker**: Offline functionality consideration
## 📊 **Testing Strategy**
### **Test Coverage**
- **Unit Tests**: Individual component testing
- **Integration Tests**: User workflow validation
- **Performance Tests**: Large batch processing
- **Error Scenarios**: Edge case handling
### **Test Data**
- **Sample Images**: Various formats and sizes
- **Text Content**: Unicode characters, special symbols
- **Network Conditions**: Slow connections, timeouts
## 🔄 **Data Flow Architecture**
```mermaid
graph TD
A[Image Upload] --> B[Image Validation]
B --> C[Image Cropping]
C --> D[Text Management]
D --> E[Panel Assembly]
E --> F[Preview]
F --> G[Export/Download]
H[Local Storage] --> D
I[Error Handling] --> B
I --> C
I --> D
I --> E
```
## 🎯 **Priority Implementation Order**
### **Phase 1: Core Features**
1. Image Upload System (drag/drop, paste, URL)
2. Image Cropping Interface
3. Text Management System
4. Canvas Rendering Engine
5. Basic Preview
### **Phase 2: Advanced Features**
1. Panel Management System
2. Batch Download System
3. User Interface Enhancements
4. Error Handling Improvements
### **Phase 3: Polish & Deployment**
1. Performance Optimization
2. Testing & Quality Assurance
3. Documentation & Deployment
4. CI/CD Pipeline
---
_Created: 2026-02-03_
_Last Updated: 2026-02-03_
+288
View File
@@ -0,0 +1,288 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Twitch Panels Creator</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="container">
<header class="header">
<div class="header-content">
<h1>Twitch Panels</h1>
<button id="themeToggle" class="theme-toggle" aria-label="Toggle theme">
<svg
class="sun-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
<svg
class="moon-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
</div>
</header>
<div class="main-grid">
<!-- Left Column -->
<div class="left-column">
<section class="card">
<h2 class="card-title">Тексты панелей</h2>
<div class="input-group">
<input
type="text"
id="panelTextInput"
placeholder="Введите текст..."
class="text-input"
/>
<button id="addTextBtn" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
<div id="textsList" class="texts-list"></div>
</section>
<section class="card">
<h2 class="card-title">Настройки текста</h2>
<div class="settings-grid">
<div class="setting-row">
<label class="setting-label">Размер</label>
<div class="setting-control">
<input type="range" id="fontSize" min="12" max="48" value="18" class="slider" />
<span class="value-display" id="fontSizeValue">18</span>
</div>
</div>
<div class="setting-row">
<label class="setting-label">Шрифт</label>
<select id="fontFamily" class="select-input">
<option value="Arial">Arial</option>
<option value="Verdana">Verdana</option>
<option value="Georgia">Georgia</option>
<option value="Times New Roman">Times New Roman</option>
<option value="Courier New">Courier New</option>
<option value="Impact">Impact</option>
<option value="Comic Sans MS">Comic Sans MS</option>
<option value="Trebuchet MS">Trebuchet MS</option>
</select>
</div>
<div class="setting-row">
<label class="setting-label">Цвет</label>
<div class="setting-control">
<input type="color" id="textColor" value="#ffffff" class="color-input" />
<span class="color-value" id="textColorValue">#ffffff</span>
</div>
</div>
<div class="setting-row">
<label class="setting-label">Выравнивание</label>
<div class="alignment-buttons">
<button class="align-btn active" data-align="left">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="17" y1="10" x2="3" y2="10"></line>
<line x1="21" y1="6" x2="3" y2="6"></line>
<line x1="21" y1="14" x2="3" y2="14"></line>
<line x1="17" y1="18" x2="3" y2="18"></line>
</svg>
</button>
<button class="align-btn" data-align="center">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="10" x2="6" y2="10"></line>
<line x1="21" y1="6" x2="3" y2="6"></line>
<line x1="21" y1="14" x2="3" y2="14"></line>
<line x1="18" y1="18" x2="6" y2="18"></line>
</svg>
</button>
<button class="align-btn" data-align="right">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="21" y1="10" x2="7" y2="10"></line>
<line x1="21" y1="6" x2="3" y2="6"></line>
<line x1="21" y1="14" x2="3" y2="14"></line>
<line x1="21" y1="18" x2="7" y2="18"></line>
</svg>
</button>
</div>
</div>
<div class="setting-row">
<label class="setting-label">Отступы</label>
<div class="setting-control">
<input type="range" id="sidePadding" min="0" max="50" value="10" class="slider" />
<span class="value-display" id="sidePaddingValue">10</span>
</div>
</div>
<div class="setting-row">
<label class="setting-label">Смещение</label>
<div class="setting-control">
<input
type="range"
id="centerOffset"
min="-50"
max="50"
value="0"
class="slider"
/>
<span class="value-display" id="centerOffsetValue">0</span>
</div>
</div>
</div>
</section>
</div>
<!-- Right Column -->
<div class="right-column">
<section class="card">
<h2 class="card-title">Фоновое изображение</h2>
<div class="crop-editor">
<div class="crop-canvas-container" id="cropCanvasContainer">
<canvas id="cropCanvas" class="crop-canvas"></canvas>
<div class="crop-box" id="cropBox">
<div class="crop-handle nw"></div>
<div class="crop-handle ne"></div>
<div class="crop-handle sw"></div>
<div class="crop-handle se"></div>
<div class="crop-handle n"></div>
<div class="crop-handle s"></div>
<div class="crop-handle w"></div>
<div class="crop-handle e"></div>
</div>
</div>
<div class="crop-controls">
<button id="uploadBgBtn" class="btn btn-secondary">
<svg 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="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
Загрузить
</button>
<input type="file" id="bgImageInput" accept="image/*" style="display: none" />
<button id="resetCropBtn" class="btn btn-outline">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="1 4 1 10 7 10"></polyline>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
</svg>
Сбросить
</button>
</div>
<div class="settings-grid">
<div class="setting-row">
<label class="setting-label">Яркость</label>
<div class="setting-control">
<input
type="range"
id="bgBrightness"
min="50"
max="150"
value="100"
class="slider"
/>
<span class="value-display" id="bgBrightnessValue">100</span>
</div>
</div>
<div class="setting-row">
<label class="setting-label">Контраст</label>
<div class="setting-control">
<input
type="range"
id="bgContrast"
min="50"
max="150"
value="100"
class="slider"
/>
<span class="value-display" id="bgContrastValue">100</span>
</div>
</div>
</div>
</div>
</section>
<section class="card">
<div class="card-header-row">
<h2 class="card-title">Панели <span class="badge" id="panelCount">0</span></h2>
<div class="panel-nav">
<button id="prevPanel" class="nav-btn" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<span id="panelIndicator" class="panel-indicator">0 / 0</span>
<button id="nextPanel" class="nav-btn" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
</div>
<div class="panel-viewer">
<div id="panelDisplay" class="panel-display">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="9" y1="9" x2="15" y2="9"></line>
</svg>
<p>Добавьте тексты для создания панелей</p>
</div>
</div>
<div class="panel-actions">
<button id="downloadCurrentBtn" class="btn btn-primary" disabled>
<svg 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>
Скачать
</button>
<button id="downloadAllBtn" class="btn btn-outline" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"></path>
<polyline points="7 11 12 16 17 11"></polyline>
<line x1="12" y1="16" x2="12" y2="4"></line>
</svg>
Скачать все
</button>
</div>
</div>
</section>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>
+625
View File
@@ -0,0 +1,625 @@
// State
let texts = [];
let panels = [];
let currentPanelIndex = 0;
let backgroundImage = null;
let originalImage = null;
let settings = {
fontSize: 18,
fontFamily: "Arial",
textColor: "#ffffff",
alignment: "left",
sidePadding: 10,
centerOffset: 0,
bgBrightness: 100,
bgContrast: 100,
};
// Crop state
let cropBox = {
x: 0,
y: 0,
width: 0,
height: 0,
};
let isDragging = false;
let isResizing = false;
let resizeHandle = null;
let dragStart = { x: 0, y: 0 };
let cropBoxStart = { x: 0, y: 0, width: 0, height: 0 };
// Initialize
document.addEventListener("DOMContentLoaded", () => {
initializeTheme();
initializeEventListeners();
loadDefaultBackground();
addDefaultTexts();
});
// Theme Management
function initializeTheme() {
const savedTheme = localStorage.getItem("theme") || "light";
document.documentElement.setAttribute("data-theme", savedTheme);
}
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute("data-theme");
const newTheme = currentTheme === "light" ? "dark" : "light";
document.documentElement.setAttribute("data-theme", newTheme);
localStorage.setItem("theme", newTheme);
}
// Event Listeners
function initializeEventListeners() {
// Theme toggle
document.getElementById("themeToggle").addEventListener("click", toggleTheme);
// Add text
document.getElementById("addTextBtn").addEventListener("click", addText);
document.getElementById("panelTextInput").addEventListener("keypress", (e) => {
if (e.key === "Enter") addText();
});
// Settings
document.getElementById("fontSize").addEventListener("input", (e) => {
settings.fontSize = parseInt(e.target.value);
document.getElementById("fontSizeValue").textContent = e.target.value;
updateAllPanels();
});
document.getElementById("fontFamily").addEventListener("change", (e) => {
settings.fontFamily = e.target.value;
updateAllPanels();
});
document.getElementById("textColor").addEventListener("input", (e) => {
settings.textColor = e.target.value;
document.getElementById("textColorValue").textContent = e.target.value;
updateAllPanels();
});
document.querySelectorAll(".align-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
document.querySelectorAll(".align-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
settings.alignment = btn.dataset.align;
updateAllPanels();
});
});
document.getElementById("sidePadding").addEventListener("input", (e) => {
settings.sidePadding = parseInt(e.target.value);
document.getElementById("sidePaddingValue").textContent = e.target.value;
updateAllPanels();
});
document.getElementById("centerOffset").addEventListener("input", (e) => {
settings.centerOffset = parseInt(e.target.value);
document.getElementById("centerOffsetValue").textContent = e.target.value;
updateAllPanels();
});
// Background controls
document.getElementById("uploadBgBtn").addEventListener("click", () => {
document.getElementById("bgImageInput").click();
});
document.getElementById("bgImageInput").addEventListener("change", handleBackgroundUpload);
document.getElementById("bgBrightness").addEventListener("input", (e) => {
settings.bgBrightness = parseInt(e.target.value);
document.getElementById("bgBrightnessValue").textContent = e.target.value;
drawCropCanvas();
updateAllPanels();
});
document.getElementById("bgContrast").addEventListener("input", (e) => {
settings.bgContrast = parseInt(e.target.value);
document.getElementById("bgContrastValue").textContent = e.target.value;
drawCropCanvas();
updateAllPanels();
});
document.getElementById("resetCropBtn").addEventListener("click", resetCrop);
// Crop canvas events
const canvas = document.getElementById("cropCanvas");
canvas.addEventListener("mousedown", handleCropMouseDown);
canvas.addEventListener("mousemove", handleCropMouseMove);
canvas.addEventListener("mouseup", handleCropMouseUp);
canvas.addEventListener("mouseleave", handleCropMouseUp);
// Panel navigation
document.getElementById("prevPanel").addEventListener("click", () => {
if (currentPanelIndex > 0) {
currentPanelIndex--;
displayCurrentPanel();
}
});
document.getElementById("nextPanel").addEventListener("click", () => {
if (currentPanelIndex < panels.length - 1) {
currentPanelIndex++;
displayCurrentPanel();
}
});
document.getElementById("downloadCurrentBtn").addEventListener("click", downloadCurrentPanel);
document.getElementById("downloadAllBtn").addEventListener("click", downloadAllPanels);
}
// Load default background
function loadDefaultBackground() {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
originalImage = img;
backgroundImage = img;
initializeCropBox();
drawCropCanvas();
updateAllPanels();
};
img.src = "https://images.unsplash.com/photo-1579546929518-9e396f3cc809?w=800&h=250&fit=crop";
}
// Add default texts
function addDefaultTexts() {
const defaultTexts = ["links", "about me", "projects"];
defaultTexts.forEach((text) => {
texts.push({ id: Date.now() + Math.random(), text });
});
renderTexts();
updateAllPanels();
}
// Add text
function addText() {
const input = document.getElementById("panelTextInput");
const text = input.value.trim();
if (!text) return;
texts.push({ id: Date.now(), text });
input.value = "";
renderTexts();
updateAllPanels();
}
// Render texts list
function renderTexts() {
const container = document.getElementById("textsList");
if (texts.length === 0) {
container.innerHTML = `
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v20M2 12h20"></path>
</svg>
<p>Добавьте тексты</p>
</div>
`;
return;
}
container.innerHTML = texts
.map(
(item) => `
<div class="text-item" data-id="${item.id}">
<input type="text" value="${item.text}" onchange="updateText(${item.id}, this.value)">
<button class="btn-delete" onclick="deleteText(${item.id})">×</button>
</div>
`,
)
.join("");
}
// Update text
window.updateText = function (id, newText) {
const item = texts.find((t) => t.id === id);
if (item) {
item.text = newText;
updateAllPanels();
}
};
// Delete text
window.deleteText = function (id) {
texts = texts.filter((t) => t.id !== id);
renderTexts();
updateAllPanels();
};
// Handle background upload
function handleBackgroundUpload(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
originalImage = img;
backgroundImage = img;
initializeCropBox();
drawCropCanvas();
updateAllPanels();
};
img.src = event.target.result;
};
reader.readAsDataURL(file);
}
// Initialize crop box
function initializeCropBox() {
const canvas = document.getElementById("cropCanvas");
const container = document.getElementById("cropCanvasContainer");
const rect = container.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
// Set crop box to full canvas initially
cropBox = {
x: 0,
y: 0,
width: canvas.width,
height: canvas.height,
};
updateCropBoxElement();
document.getElementById("cropBox").classList.add("active");
}
// Draw crop canvas
function drawCropCanvas() {
const canvas = document.getElementById("cropCanvas");
const ctx = canvas.getContext("2d");
if (!originalImage) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Apply filters
ctx.filter = `brightness(${settings.bgBrightness}%) contrast(${settings.bgContrast}%)`;
// Draw image to fit canvas
const scale = Math.max(canvas.width / originalImage.width, canvas.height / originalImage.height);
const scaledWidth = originalImage.width * scale;
const scaledHeight = originalImage.height * scale;
const x = (canvas.width - scaledWidth) / 2;
const y = (canvas.height - scaledHeight) / 2;
ctx.drawImage(originalImage, x, y, scaledWidth, scaledHeight);
ctx.filter = "none";
}
// Update crop box element
function updateCropBoxElement() {
const element = document.getElementById("cropBox");
element.style.left = cropBox.x + "px";
element.style.top = cropBox.y + "px";
element.style.width = cropBox.width + "px";
element.style.height = cropBox.height + "px";
}
// Crop mouse handlers
function handleCropMouseDown(e) {
const rect = e.target.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Check if clicking on a handle
const handles = document.querySelectorAll(".crop-handle");
let clickedHandle = null;
handles.forEach((handle) => {
const handleRect = handle.getBoundingClientRect();
const handleX = handleRect.left - rect.left + handleRect.width / 2;
const handleY = handleRect.top - rect.top + handleRect.height / 2;
if (Math.abs(x - handleX) < 10 && Math.abs(y - handleY) < 10) {
clickedHandle = handle.classList[1]; // Get handle class (nw, ne, etc.)
}
});
if (clickedHandle) {
isResizing = true;
resizeHandle = clickedHandle;
} else if (
x >= cropBox.x &&
x <= cropBox.x + cropBox.width &&
y >= cropBox.y &&
y <= cropBox.y + cropBox.height
) {
isDragging = true;
}
dragStart = { x, y };
cropBoxStart = { ...cropBox };
}
function handleCropMouseMove(e) {
if (!isDragging && !isResizing) return;
const rect = e.target.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const dx = x - dragStart.x;
const dy = y - dragStart.y;
if (isDragging) {
cropBox.x = Math.max(0, Math.min(cropBoxStart.x + dx, rect.width - cropBox.width));
cropBox.y = Math.max(0, Math.min(cropBoxStart.y + dy, rect.height - cropBox.height));
} else if (isResizing) {
const minSize = 50;
switch (resizeHandle) {
case "nw":
cropBox.x = Math.max(
0,
Math.min(cropBoxStart.x + dx, cropBoxStart.x + cropBoxStart.width - minSize),
);
cropBox.y = Math.max(
0,
Math.min(cropBoxStart.y + dy, cropBoxStart.y + cropBoxStart.height - minSize),
);
cropBox.width = cropBoxStart.width - (cropBox.x - cropBoxStart.x);
cropBox.height = cropBoxStart.height - (cropBox.y - cropBoxStart.y);
break;
case "ne":
cropBox.y = Math.max(
0,
Math.min(cropBoxStart.y + dy, cropBoxStart.y + cropBoxStart.height - minSize),
);
cropBox.width = Math.max(
minSize,
Math.min(cropBoxStart.width + dx, rect.width - cropBoxStart.x),
);
cropBox.height = cropBoxStart.height - (cropBox.y - cropBoxStart.y);
break;
case "sw":
cropBox.x = Math.max(
0,
Math.min(cropBoxStart.x + dx, cropBoxStart.x + cropBoxStart.width - minSize),
);
cropBox.width = cropBoxStart.width - (cropBox.x - cropBoxStart.x);
cropBox.height = Math.max(
minSize,
Math.min(cropBoxStart.height + dy, rect.height - cropBoxStart.y),
);
break;
case "se":
cropBox.width = Math.max(
minSize,
Math.min(cropBoxStart.width + dx, rect.width - cropBoxStart.x),
);
cropBox.height = Math.max(
minSize,
Math.min(cropBoxStart.height + dy, rect.height - cropBoxStart.y),
);
break;
case "n":
cropBox.y = Math.max(
0,
Math.min(cropBoxStart.y + dy, cropBoxStart.y + cropBoxStart.height - minSize),
);
cropBox.height = cropBoxStart.height - (cropBox.y - cropBoxStart.y);
break;
case "s":
cropBox.height = Math.max(
minSize,
Math.min(cropBoxStart.height + dy, rect.height - cropBoxStart.y),
);
break;
case "w":
cropBox.x = Math.max(
0,
Math.min(cropBoxStart.x + dx, cropBoxStart.x + cropBoxStart.width - minSize),
);
cropBox.width = cropBoxStart.width - (cropBox.x - cropBoxStart.x);
break;
case "e":
cropBox.width = Math.max(
minSize,
Math.min(cropBoxStart.width + dx, rect.width - cropBoxStart.x),
);
break;
}
}
updateCropBoxElement();
}
function handleCropMouseUp() {
if (isDragging || isResizing) {
applyCrop();
}
isDragging = false;
isResizing = false;
resizeHandle = null;
}
// Apply crop
function applyCrop() {
if (!originalImage) return;
const canvas = document.getElementById("cropCanvas");
const tempCanvas = document.createElement("canvas");
const tempCtx = tempCanvas.getContext("2d");
// Calculate scale factor
const scale = Math.max(canvas.width / originalImage.width, canvas.height / originalImage.height);
const scaledWidth = originalImage.width * scale;
const scaledHeight = originalImage.height * scale;
const offsetX = (canvas.width - scaledWidth) / 2;
const offsetY = (canvas.height - scaledHeight) / 2;
// Calculate crop area in original image coordinates
const cropX = (cropBox.x - offsetX) / scale;
const cropY = (cropBox.y - offsetY) / scale;
const cropWidth = cropBox.width / scale;
const cropHeight = cropBox.height / scale;
tempCanvas.width = cropWidth;
tempCanvas.height = cropHeight;
tempCtx.drawImage(
originalImage,
cropX,
cropY,
cropWidth,
cropHeight,
0,
0,
cropWidth,
cropHeight,
);
const img = new Image();
img.onload = () => {
backgroundImage = img;
updateAllPanels();
};
img.src = tempCanvas.toDataURL();
}
// Reset crop
function resetCrop() {
loadDefaultBackground();
}
// Update all panels
function updateAllPanels() {
panels = texts.map((item) => ({
id: item.id,
text: item.text,
}));
currentPanelIndex = Math.min(currentPanelIndex, Math.max(0, panels.length - 1));
displayCurrentPanel();
updatePanelNavigation();
}
// Display current panel
function displayCurrentPanel() {
const container = document.getElementById("panelDisplay");
if (panels.length === 0) {
container.innerHTML = `
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="9" y1="9" x2="15" y2="9"></line>
</svg>
<p>Добавьте тексты для создания панелей</p>
</div>
`;
return;
}
const panel = panels[currentPanelIndex];
container.innerHTML = `<canvas class="panel-canvas" id="currentPanelCanvas"></canvas>`;
setTimeout(() => {
drawPanel(panel, "currentPanelCanvas");
}, 0);
}
// Update panel navigation
function updatePanelNavigation() {
document.getElementById("panelCount").textContent = panels.length;
document.getElementById("panelIndicator").textContent =
panels.length > 0 ? `${currentPanelIndex + 1} / ${panels.length}` : "0 / 0";
document.getElementById("prevPanel").disabled = currentPanelIndex === 0 || panels.length === 0;
document.getElementById("nextPanel").disabled =
currentPanelIndex >= panels.length - 1 || panels.length === 0;
document.getElementById("downloadCurrentBtn").disabled = panels.length === 0;
document.getElementById("downloadAllBtn").disabled = panels.length === 0;
}
// Draw panel
function drawPanel(panel, canvasId) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext("2d");
canvas.width = 320;
canvas.height = 100;
// Draw background
if (backgroundImage) {
ctx.filter = `brightness(${settings.bgBrightness}%) contrast(${settings.bgContrast}%)`;
const scale = Math.max(
canvas.width / backgroundImage.width,
canvas.height / backgroundImage.height,
);
const scaledWidth = backgroundImage.width * scale;
const scaledHeight = backgroundImage.height * scale;
const x = (canvas.width - scaledWidth) / 2;
const y = (canvas.height - scaledHeight) / 2;
ctx.drawImage(backgroundImage, x, y, scaledWidth, scaledHeight);
ctx.filter = "none";
} else {
ctx.fillStyle = "#667eea";
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
// Draw text
ctx.font = `${settings.fontSize}px ${settings.fontFamily}`;
ctx.fillStyle = settings.textColor;
ctx.textBaseline = "middle";
const text = panel.text;
let x;
if (settings.alignment === "left") {
x = settings.sidePadding;
ctx.textAlign = "left";
} else if (settings.alignment === "center") {
x = canvas.width / 2 + settings.centerOffset;
ctx.textAlign = "center";
} else {
x = canvas.width - settings.sidePadding;
ctx.textAlign = "right";
}
const y = canvas.height / 2;
ctx.fillText(text, x, y);
}
// Download current panel
function downloadCurrentPanel() {
if (panels.length === 0) return;
const canvas = document.getElementById("currentPanelCanvas");
const panel = panels[currentPanelIndex];
if (canvas && panel) {
const link = document.createElement("a");
link.download = `${panel.text}.png`;
link.href = canvas.toDataURL();
link.click();
}
}
// Download all panels
function downloadAllPanels() {
panels.forEach((panel, index) => {
setTimeout(() => {
const tempCanvas = document.createElement("canvas");
tempCanvas.id = `temp-canvas-${panel.id}`;
document.body.appendChild(tempCanvas);
drawPanel(panel, tempCanvas.id);
const link = document.createElement("a");
link.download = `${panel.text}.png`;
link.href = tempCanvas.toDataURL();
link.click();
document.body.removeChild(tempCanvas);
}, 100 * index);
});
}
+763
View File
@@ -0,0 +1,763 @@
:root {
--bg-primary: #ffffff;
--bg-secondary: #fafafa;
--bg-card: #ffffff;
--bg-hover: #f5f5f5;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--text-tertiary: #999999;
--border-color: #e5e5e5;
--border-hover: #d4d4d4;
--accent-primary: #6366f1;
--accent-hover: #4f46e5;
--accent-light: #eef2ff;
--danger: #ef4444;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.1);
--radius: 8px;
--transition: all 0.15s ease;
}
[data-theme="dark"] {
--bg-primary: #0a0a0a;
--bg-secondary: #1a1a1a;
--bg-card: #1a1a1a;
--bg-hover: #2a2a2a;
--text-primary: #f5f5f5;
--text-secondary: #a3a3a3;
--text-tertiary: #737373;
--border-color: #2a2a2a;
--border-hover: #3a3a3a;
--accent-primary: #818cf8;
--accent-hover: #6366f1;
--accent-light: #312e81;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.4);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
padding: 16px;
transition: var(--transition);
line-height: 1.5;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
/* Header */
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 16px 20px;
border-radius: var(--radius);
margin-bottom: 16px;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-size: 20px;
font-weight: 600;
}
.theme-toggle {
width: 36px;
height: 36px;
border-radius: 6px;
border: none;
background: rgba(255, 255, 255, 0.2);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition);
position: relative;
}
.theme-toggle:hover {
background: rgba(255, 255, 255, 0.3);
}
.theme-toggle svg {
width: 18px;
height: 18px;
position: absolute;
transition: var(--transition);
}
.sun-icon {
opacity: 1;
transform: rotate(0deg);
}
.moon-icon {
opacity: 0;
transform: rotate(90deg);
}
[data-theme="dark"] .sun-icon {
opacity: 0;
transform: rotate(90deg);
}
[data-theme="dark"] .moon-icon {
opacity: 1;
transform: rotate(0deg);
}
/* Main Grid */
.main-grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
@media (min-width: 1024px) {
.main-grid {
grid-template-columns: 1fr 1fr;
}
}
/* Card */
.card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 16px;
margin-bottom: 16px;
transition: var(--transition);
}
.card-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.card-header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
/* Badge */
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
background: var(--accent-light);
color: var(--accent-primary);
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
/* Input Group */
.input-group {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.text-input {
flex: 1;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius);
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
transition: var(--transition);
font-family: inherit;
}
.text-input:focus {
outline: none;
border-color: var(--accent-primary);
}
.text-input::placeholder {
color: var(--text-tertiary);
}
/* Buttons */
.btn {
padding: 10px 16px;
border: none;
border-radius: var(--radius);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: var(--transition);
display: inline-flex;
align-items: center;
gap: 6px;
font-family: inherit;
white-space: nowrap;
}
.btn svg {
width: 16px;
height: 16px;
}
.btn-primary {
background: var(--accent-primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: var(--bg-hover);
}
.btn-outline {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
.btn-outline:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.btn-outline:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-delete {
background: var(--danger);
color: white;
padding: 6px 10px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: var(--transition);
min-width: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.btn-delete:hover {
background: #dc2626;
}
/* Texts List */
.texts-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.text-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius);
transition: var(--transition);
}
.text-item:hover {
border-color: var(--border-hover);
}
.text-item input {
flex: 1;
padding: 6px 10px;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
transition: var(--transition);
font-family: inherit;
}
.text-item input:focus {
outline: none;
border-color: var(--accent-primary);
}
/* Settings */
.settings-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.setting-row {
display: grid;
grid-template-columns: 100px 1fr;
align-items: center;
gap: 12px;
}
.setting-label {
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
}
.setting-control {
display: flex;
align-items: center;
gap: 10px;
}
.slider {
flex: 1;
height: 4px;
border-radius: 2px;
background: var(--bg-secondary);
outline: none;
-webkit-appearance: none;
appearance: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent-primary);
cursor: pointer;
transition: var(--transition);
}
.slider::-webkit-slider-thumb:hover {
transform: scale(1.1);
}
.slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent-primary);
cursor: pointer;
border: none;
transition: var(--transition);
}
.slider::-moz-range-thumb:hover {
transform: scale(1.1);
}
.value-display {
color: var(--text-primary);
font-weight: 500;
min-width: 40px;
text-align: right;
font-size: 13px;
padding: 4px 8px;
background: var(--bg-secondary);
border-radius: 4px;
}
.select-input {
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: var(--radius);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
transition: var(--transition);
font-family: inherit;
width: 100%;
}
.select-input:focus {
outline: none;
border-color: var(--accent-primary);
}
.color-input {
width: 40px;
height: 32px;
border: 1px solid var(--border-color);
border-radius: var(--radius);
cursor: pointer;
transition: var(--transition);
background: var(--bg-primary);
}
.color-input:hover {
border-color: var(--accent-primary);
}
.color-value {
font-family: "Courier New", monospace;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
padding: 4px 8px;
background: var(--bg-secondary);
border-radius: 4px;
}
.alignment-buttons {
display: flex;
gap: 6px;
}
.align-btn {
flex: 1;
padding: 8px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
border-radius: var(--radius);
cursor: pointer;
transition: var(--transition);
display: flex;
align-items: center;
justify-content: center;
}
.align-btn svg {
width: 16px;
height: 16px;
stroke: var(--text-secondary);
transition: var(--transition);
}
.align-btn:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.align-btn:hover svg {
stroke: var(--accent-primary);
}
.align-btn.active {
background: var(--accent-primary);
border-color: var(--accent-primary);
}
.align-btn.active svg {
stroke: white;
}
/* Crop Editor */
.crop-editor {
display: flex;
flex-direction: column;
gap: 12px;
}
.crop-canvas-container {
position: relative;
width: 100%;
aspect-ratio: 16 / 5;
background: var(--bg-secondary);
border-radius: var(--radius);
overflow: hidden;
cursor: crosshair;
}
.crop-canvas {
width: 100%;
height: 100%;
display: block;
}
.crop-box {
position: absolute;
border: 2px solid var(--accent-primary);
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
cursor: move;
display: none;
}
.crop-box.active {
display: block;
}
.crop-handle {
position: absolute;
width: 10px;
height: 10px;
background: white;
border: 2px solid var(--accent-primary);
border-radius: 50%;
}
.crop-handle.nw {
top: -5px;
left: -5px;
cursor: nw-resize;
}
.crop-handle.ne {
top: -5px;
right: -5px;
cursor: ne-resize;
}
.crop-handle.sw {
bottom: -5px;
left: -5px;
cursor: sw-resize;
}
.crop-handle.se {
bottom: -5px;
right: -5px;
cursor: se-resize;
}
.crop-handle.n {
top: -5px;
left: 50%;
transform: translateX(-50%);
cursor: n-resize;
}
.crop-handle.s {
bottom: -5px;
left: 50%;
transform: translateX(-50%);
cursor: s-resize;
}
.crop-handle.w {
left: -5px;
top: 50%;
transform: translateY(-50%);
cursor: w-resize;
}
.crop-handle.e {
right: -5px;
top: 50%;
transform: translateY(-50%);
cursor: e-resize;
}
.crop-controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* Panel Viewer */
.panel-nav {
display: flex;
align-items: center;
gap: 8px;
}
.nav-btn {
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--border-color);
background: var(--bg-primary);
border-radius: var(--radius);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition);
}
.nav-btn:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.nav-btn:hover:not(:disabled) svg {
stroke: var(--accent-primary);
}
.nav-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.nav-btn svg {
width: 16px;
height: 16px;
stroke: var(--text-secondary);
}
.panel-indicator {
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
min-width: 50px;
text-align: center;
}
.panel-viewer {
display: flex;
flex-direction: column;
gap: 12px;
}
.panel-display {
background: var(--bg-secondary);
border-radius: var(--radius);
padding: 20px;
display: flex;
justify-content: center;
align-items: center;
min-height: 150px;
}
.panel-canvas {
border-radius: var(--radius);
display: block;
max-width: 100%;
box-shadow: var(--shadow-md);
}
.panel-actions {
display: flex;
gap: 8px;
}
.panel-actions .btn {
flex: 1;
justify-content: center;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 32px 16px;
color: var(--text-tertiary);
}
.empty-state svg {
width: 48px;
height: 48px;
margin: 0 auto 12px;
opacity: 0.5;
}
.empty-state p {
font-size: 14px;
}
/* Responsive */
@media (max-width: 768px) {
body {
padding: 12px;
}
.header {
padding: 12px 16px;
}
.header h1 {
font-size: 18px;
}
.setting-row {
grid-template-columns: 80px 1fr;
gap: 10px;
}
.crop-controls {
flex-direction: column;
}
.crop-controls .btn {
width: 100%;
justify-content: center;
}
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.text-item {
animation: fadeIn 0.2s ease-out;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-hover);
}
+61
View File
@@ -0,0 +1,61 @@
import { readFileSync } from "fs";
import { globSync } from "glob";
const includePatterns = ["src/**/*.svelte", "src/**/*.css"];
const files = globSync(includePatterns);
const defined = new Set();
const used = new Set();
const usageLocations = [];
files.forEach((file) => {
const content = readFileSync(file, "utf-8");
const lines = content.split("\n");
const defMatches = content.matchAll(/--[\w-]+(?=\s*:)/g);
for (const m of defMatches) {
defined.add(m[0]);
}
lines.forEach((line, idx) => {
const varMatches = line.matchAll(/var\(\s*--[\w-]+\s*\)/g);
for (const match of varMatches) {
const varName = match[0].slice(4, -1).trim();
used.add(varName);
usageLocations.push({
file,
line: idx + 1,
column: match.index + 1,
varName,
});
}
});
});
let hasUndefined = false;
const undefinedErrors = usageLocations.filter((loc) => !defined.has(loc.varName));
undefinedErrors.forEach(({ file, line, column, varName }) => {
console.error(`${file}:${line}:${column} — не определена CSS-переменная "${varName}"`);
hasUndefined = true;
});
const unused = [...defined].filter((varName) => !used.has(varName));
if (unused.length > 0) {
console.warn("\n⚠️ Объявлены, но нигде не используются:");
unused.forEach((varName) => console.warn(` ${varName}`));
}
if (hasUndefined) {
console.error("\n❌ Найдены неопределённые переменные. Исправьте их.");
process.exit(1);
} else {
if (unused.length === 0) {
console.log("\n✅ Все CSS-переменные определены и используются.");
} else {
console.log("\n✅ Неопределённых переменных нет, но есть неиспользуемые (см. предупреждения).");
}
process.exit(0);
}
+78
View File
@@ -0,0 +1,78 @@
:root {
--brand-main: #86efac;
--brand-alt: oklch(from var(--brand-main) l c calc(h + 36));
--action-lightness: 0.6;
--action-chroma: 0.18;
--hover-step: -0.08;
--danger-base: #ef4444;
--surface-main: oklch(from var(--brand-main) 0.99 0.02 h);
--surface-subtle: oklch(from var(--brand-main) 0.95 0.1 h);
--surface-elevated: oklch(from var(--brand-main) 0.99 0.05 h);
--surface-control: oklch(from var(--brand-main) 0.85 0.04 h);
--surface-hover: oklch(from var(--brand-main) 0.9 0.1 h);
--action-primary: oklch(from var(--brand-main) var(--action-lightness) var(--action-chroma) h);
--action-primary-hover: oklch(from var(--action-primary) calc(l - var(--hover-step)) c h);
--action-secondary: oklch(from var(--brand-alt) var(--action-lightness) var(--action-chroma) h);
--action-secondary-hover: oklch(from var(--action-secondary) calc(l - var(--hover-step)) c h);
--text-main: oklch(from var(--brand-main) 0.25 0.02 h);
--text-muted: oklch(from var(--brand-main) 0.45 0.02 h);
--text-action: oklch(from var(--brand-main) 0.99 0.02 h);
--border-main: oklch(from var(--brand-main) 0.9 0.04 h);
--border-strong: oklch(from var(--brand-main) 0.8 0.06 h);
--shadow: 0 1px 3px oklch(from var(--brand-main) 0.2 0.05 h / 0.1);
--shadow-md: 0 4px 12px oklch(from var(--brand-main) 0.2 0.05 h / 0.1);
--radius: 8px;
--transition: all 0.3s ease;
}
[data-theme="dark"] {
--action-lightness: 0.5;
--action-chroma: 0.18;
--surface-main: oklch(from var(--brand-main) 0.12 0.02 h);
--surface-subtle: oklch(from var(--brand-main) 0.16 0.03 h);
--surface-elevated: oklch(from var(--brand-main) 0.2 0.03 h);
--surface-control: oklch(from var(--brand-main) 0.4 0.06 h);
--surface-hover: oklch(from var(--brand-main) 0.24 0.04 h);
--action-primary-hover: oklch(from var(--action-primary) calc(l + var(--hover-step)) c h);
--action-secondary-hover: oklch(from var(--action-secondary) calc(l + var(--hover-step)) c h);
--text-main: oklch(from var(--brand-main) 0.94 0.01 h);
--text-muted: oklch(from var(--brand-main) 0.75 0.02 h);
--border-main: oklch(from var(--brand-main) 0.26 0.04 h);
--border-strong: oklch(from var(--brand-main) 0.4 0.06 h);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
ul {
list-style: none;
text-indent: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif;
background: var(--surface-main);
color: var(--text-main);
min-height: 100vh;
padding: 16px;
transition: var(--transition);
line-height: 1.5;
}
+21 -8
View File
@@ -1,11 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
(function () {
try {
const saved = localStorage.getItem("theme");
if (saved) {
const theme = JSON.parse(saved).theme;
if (theme === "dark") {
document.documentElement.setAttribute("data-theme", "dark");
}
}
} catch (e) {}
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
@@ -1,24 +0,0 @@
<script lang="ts">
interface Props {
errorMessage: string | undefined;
}
let { errorMessage }: Props = $props();
</script>
{#if errorMessage}
<div class="error-message">
{errorMessage}
</div>
{/if}
<style>
.error-message {
background: #ffebee;
color: #c62828;
padding: 1rem;
border-radius: 8px;
border: 1px solid #ffcdd2;
font-weight: 500;
}
</style>
@@ -1,53 +0,0 @@
<script lang="ts">
import Button from "$components/ui/Button.svelte";
interface Props {
backgroundImage?: string | undefined;
onUploadNewImage: () => void;
}
let { backgroundImage, onUploadNewImage }: Props = $props();
</script>
{#if backgroundImage}
<div class="background-preview">
<div class="background-header">
<h3>Фоновое изображение</h3>
<Button variant="secondary" size="sm" onclick={onUploadNewImage}>Загрузить</Button>
</div>
<img src={backgroundImage} alt="Фон" />
</div>
{/if}
<style>
.background-preview {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.background-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.background-preview h3 {
margin: 0;
color: #333;
font-size: 1rem;
font-weight: 600;
}
.background-preview img {
width: 100%;
max-width: 320px;
height: auto;
border-radius: 4px;
}
</style>
+149
View File
@@ -0,0 +1,149 @@
<script lang="ts">
import { IMAGE_SETTINGS, PANEL_SETTINGS } from "$lib/constants";
import { imageState } from "$states/image.svelte";
import Konva from "konva";
import { onMount } from "svelte";
import { Image, Layer, Stage } from "svelte-konva";
let active = $state(false);
let masterImage: Image | undefined = $state();
let contrast = $derived(imageState.appliedFilters.contrast);
let brightness = $derived(imageState.appliedFilters.brightness);
let hue = $derived(imageState.appliedFilters.hue);
let saturation = $derived(imageState.appliedFilters.saturation);
let luminance = $derived(imageState.appliedFilters.luminance);
function updateMasterImage() {
if (masterImage?.node && masterImage?.node?.width() > 0) {
masterImage?.node.cache();
imageState.masterImage = masterImage?.node.toCanvas();
}
}
$effect(() => {
void contrast;
void brightness;
void hue;
void saturation;
void luminance;
updateMasterImage();
});
onMount(async () => {
updateMasterImage();
});
</script>
<div class="crop-canvas-container">
<Stage width={PANEL_SETTINGS.PANEL_WIDTH} height={PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT}>
<Layer>
<Image
image={imageState.croppedImage}
bind:this={masterImage}
filters={[Konva.Filters.Brightness, Konva.Filters.Contrast, Konva.Filters.HSL]}
brightness={brightness / IMAGE_SETTINGS.BRIGHTNESS_DIVIDER}
{contrast}
{hue}
saturation={saturation / IMAGE_SETTINGS.SATURATION_DIVIDER}
luminance={luminance / IMAGE_SETTINGS.LUMINANCE_DIVIDER}
/>
</Layer>
</Stage>
<div class="crop-box" class:active>
<div class="crop-handle nw"></div>
<div class="crop-handle ne"></div>
<div class="crop-handle sw"></div>
<div class="crop-handle se"></div>
<div class="crop-handle n"></div>
<div class="crop-handle s"></div>
<div class="crop-handle w"></div>
<div class="crop-handle e"></div>
</div>
</div>
<style>
.crop-canvas-container {
position: relative;
width: 100%;
aspect-ratio: 16 / 5;
background: var(--surface-subtle);
border-radius: var(--radius);
overflow: hidden;
cursor: crosshair;
}
.crop-box {
position: absolute;
border: 2px solid var(--action-primary);
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
cursor: move;
display: none;
}
.crop-box.active {
display: block;
}
.crop-handle {
position: absolute;
width: 10px;
height: 10px;
background: white;
border: 2px solid var(--action-primary);
border-radius: 50%;
}
.crop-handle.nw {
top: -5px;
left: -5px;
cursor: nw-resize;
}
.crop-handle.ne {
top: -5px;
right: -5px;
cursor: ne-resize;
}
.crop-handle.sw {
bottom: -5px;
left: -5px;
cursor: sw-resize;
}
.crop-handle.se {
bottom: -5px;
right: -5px;
cursor: se-resize;
}
.crop-handle.n {
top: -5px;
left: 50%;
transform: translateX(-50%);
cursor: n-resize;
}
.crop-handle.s {
bottom: -5px;
left: 50%;
transform: translateX(-50%);
cursor: s-resize;
}
.crop-handle.w {
left: -5px;
top: 50%;
transform: translateY(-50%);
cursor: w-resize;
}
.crop-handle.e {
right: -5px;
top: 50%;
transform: translateY(-50%);
cursor: e-resize;
}
</style>
-214
View File
@@ -1,214 +0,0 @@
<script lang="ts">
import Button from "$components/ui/Button.svelte";
import type { ImageCropResult } from "$lib/types/panel";
import { setLoading } from "$stores/uiStore";
import CropperJS from "cropperjs";
import { onMount } from "svelte";
interface Props {
imageSrc: string;
onCropComplete: (croppedImage: string) => void;
onCancel: () => void;
}
let { imageSrc, onCropComplete, onCancel }: Props = $props();
let imageElement: HTMLImageElement;
let cropper: CropperJS | undefined = undefined;
let isProcessing = $state(false);
let errorMessage = $state<string | undefined>(undefined);
onMount(() => {
initializeCropper();
return () => {
if (cropper) {
cropper.destroy();
}
};
});
function initializeCropper() {
if (!imageElement || !CropperJS) {
return;
}
cropper = new CropperJS(imageElement);
}
async function handleCrop() {
if (!cropper) {
return;
}
try {
isProcessing = true;
errorMessage = undefined;
setLoading(true);
const cropperCanvasElement = cropper.getCropperCanvas();
if (!cropperCanvasElement) {
throw new Error("Не удалось получить обрезанное изображение");
}
const canvas = await cropperCanvasElement.$toCanvas({
width: 320,
});
if (!canvas) {
throw new Error("Не удалось получить canvas");
}
const croppedImage = canvas.toDataURL("image/png");
const cropResult: ImageCropResult = {
success: true,
croppedImage,
};
onCropComplete(croppedImage);
} catch (error) {
errorMessage = error instanceof Error ? error.message : "Ошибка обрезки изображения";
} finally {
isProcessing = false;
setLoading(false);
}
}
function handleCancel() {
if (cropper) {
cropper.destroy();
}
onCancel();
}
</script>
<div class="cropper-container">
<div class="cropper-wrapper">
<img bind:this={imageElement} src={imageSrc} alt="" class="cropper-image" />
</div>
{#if errorMessage}
<div class="error-message">
<strong>Ошибка:</strong>
{errorMessage}
</div>
{/if}
<div class="cropper-actions">
<Button variant="secondary" onclick={handleCancel} disabled={isProcessing}>Отмена</Button>
<Button variant="primary" onclick={handleCrop} disabled={isProcessing} loading={isProcessing}>Применить</Button>
</div>
<div class="cropper-info">
<p>💡 Изображение будет обрезано до ширины 320px</p>
</div>
</div>
<style>
.cropper-container {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
max-width: 800px;
margin: 0 auto;
}
.cropper-wrapper {
position: relative;
width: 100%;
max-height: 600px;
overflow: hidden;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.cropper-image {
display: block;
max-width: 100%;
height: auto;
}
.cropper-actions {
display: flex;
gap: 1rem;
justify-content: center;
padding: 1rem;
background: #f9f9f9;
border-radius: 8px;
}
.cropper-info {
text-align: center;
color: #666;
font-size: 0.9rem;
padding: 0.5rem;
}
.error-message {
background: #ffebee;
color: #c62828;
padding: 0.75rem;
border-radius: 4px;
border: 1px solid #ffcdd2;
}
:global(.cropper-view-box),
:global(.cropper-face) {
border-radius: 0;
}
:global(.cropper-line) {
background-color: rgba(0, 123, 255, 0.5);
}
:global(.cropper-point) {
background-color: #007bff;
}
:global(.cropper-container) {
direction: ltr;
font-size: 0;
line-height: 0;
position: relative;
touch-action: none;
user-select: none;
}
:global(.cropper-container img) {
display: block;
height: 100%;
image-orientation: 0deg;
max-height: none !important;
max-width: none !important;
min-height: 0 !important;
min-width: 0 !important;
width: 100%;
}
:global(.cropper-wrap-box, .cropper-canvas, .cropper-drag-box, .cropper-crop-box, .cropper-modal) {
bottom: 0;
left: 0;
position: absolute;
right: 0;
top: 0;
}
:global(.cropper-wrap-box, .cropper-canvas) {
overflow: hidden;
}
:global(.cropper-drag-box) {
background-color: #fff;
opacity: 0;
}
:global(.cropper-modal) {
background-color: rgba(0, 0, 0, 0.5);
}
:global(.cropper-crop-box) {
border-color: rgba(0, 123, 255, 0.5);
}
</style>
+97 -20
View File
@@ -1,20 +1,97 @@
<script lang="ts">
import ImageUpload from "./ImageUpload.svelte";
import ImageCropper from "./ImageCropper.svelte";
interface Props {
currentStep: string;
uploadedImage: string | undefined;
onImageUpload: (image: string) => void;
onCropComplete: (croppedImage: string) => void;
onCropCancel: () => void;
}
let { currentStep, uploadedImage, onImageUpload, onCropComplete, onCropCancel }: Props = $props();
</script>
{#if currentStep === "upload"}
<ImageUpload onImageSelect={onImageUpload} />
{:else if currentStep === "crop" && uploadedImage}
<ImageCropper imageSrc={uploadedImage} {onCropComplete} onCancel={onCropCancel} />
{/if}
<script lang="ts">
import Card from "$components/layout/Card.svelte";
import SettingsGrid from "$components/layout/SettingsGrid.svelte";
import SettingsRow from "$components/layout/SettingsRow.svelte";
import Button from "$components/ui/Button.svelte";
import RangeSlider from "$components/ui/RangeSlider.svelte";
import { IMAGE_SETTINGS } from "$lib/constants";
import { uploadService } from "$services/uploadService";
import { imageState } from "$states/image.svelte";
import Pencil from "~icons/lucide/pencil";
import Reset from "~icons/lucide/rotate-ccw";
import Sliders from "~icons/lucide/sliders-horizontal";
import Upload from "~icons/lucide/upload";
import CropInline from "./CropInline.svelte";
async function handlePaste(event: ClipboardEvent) {
let image = await uploadService.fromClipboard(event);
if (image.ok) {
imageState.fullImage = image.data;
}
}
</script>
<Card title="Фоновое изображение">
<div class="crop-editor">
<CropInline />
<div class="crop-controls">
<Button label="Загрузить" ariaLabel="Upload" icon={Upload} type="primary" />
<Button label="Редактировать" ariaLabel="Edit" icon={Pencil} type="secondary" />
<Button label="Сбросить" ariaLabel="Reset" icon={Reset} type="outline" />
</div>
<SettingsGrid label="Настройки изображения" icon={Sliders}>
<SettingsRow label="Сдвиг цвета">
<RangeSlider
bind:value={imageState.hue}
min={IMAGE_SETTINGS.HUE_MIN}
max={IMAGE_SETTINGS.HUE_MAX}
defaultValue={IMAGE_SETTINGS.HUE_DEFAULT}
showReset={true}
/>
</SettingsRow>
<SettingsRow label="Насыщенность">
<RangeSlider
bind:value={imageState.saturation}
min={IMAGE_SETTINGS.SATURATION_MIN}
max={IMAGE_SETTINGS.SATURATION_MAX}
defaultValue={IMAGE_SETTINGS.SATURATION_DEFAULT}
showReset={true}
/>
</SettingsRow>
<SettingsRow label="Яркость">
<RangeSlider
bind:value={imageState.luminance}
min={IMAGE_SETTINGS.LUMINANCE_MIN}
max={IMAGE_SETTINGS.LUMINANCE_MAX}
defaultValue={IMAGE_SETTINGS.LUMINANCE_DEFAULT}
showReset={true}
/>
</SettingsRow>
<SettingsRow label="Светлота">
<RangeSlider
bind:value={imageState.brightness}
min={IMAGE_SETTINGS.BRIGHTNESS_MIN}
max={IMAGE_SETTINGS.BRIGHTNESS_MAX}
defaultValue={IMAGE_SETTINGS.BRIGHTNESS_DEFAULT}
showReset={true}
/>
</SettingsRow>
<SettingsRow label="Контраст">
<RangeSlider
bind:value={imageState.contrast}
min={IMAGE_SETTINGS.CONTRAST_MIN}
max={IMAGE_SETTINGS.CONTRAST_MAX}
defaultValue={IMAGE_SETTINGS.CONTRAST_DEFAULT}
showReset={true}
/>
</SettingsRow>
</SettingsGrid>
</div>
</Card>
<svelte:window onpaste={handlePaste} />
<style>
.crop-editor {
display: flex;
flex-direction: column;
gap: 12px;
}
.crop-controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
</style>
-401
View File
@@ -1,401 +0,0 @@
<script lang="ts">
import Button from "$components/ui/Button.svelte";
import type { ImageUploadResult } from "$lib/types/panel";
import { handleError } from "$lib/utils/errorHandler";
import { clearError, setCurrentStep, setLoading, uiStore } from "$stores/uiStore";
import { onMount } from "svelte";
import { imageService } from "../../services/imageService";
interface Props {
onImageSelect: (image: string) => void;
}
let { onImageSelect }: Props = $props();
let dropZone = $state<HTMLElement | undefined>(undefined);
let fileInput = $state<HTMLInputElement | undefined>(undefined);
let urlInput = $state<HTMLInputElement | undefined>(undefined);
let urlForm = $state<HTMLFormElement | undefined>(undefined);
let isDragOver = $state(false);
let showUrlInput = $state(false);
let uploadedImage = $state<string | undefined>(undefined);
let errorMessage = $state<string | undefined>(undefined);
const uiError = $derived($uiStore.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 {
setLoading(true);
clearError();
const result: ImageUploadResult = await imageService.handleFileUpload(file);
if (result.success && result.image) {
uploadedImage = result.image;
onImageSelect(result.image);
setCurrentStep("crop");
} else {
errorMessage = result.error || "Ошибка загрузки файла";
}
} catch (error) {
errorMessage = handleError(error);
} finally {
setLoading(false);
}
}
async function handlePaste(e: ClipboardEvent) {
e.preventDefault();
try {
setLoading(true);
clearError();
const result: ImageUploadResult = await imageService.handlePasteUpload(e);
if (result.success && result.image) {
uploadedImage = result.image;
onImageSelect(result.image);
setCurrentStep("crop");
} else {
errorMessage = result.error || "Ошибка вставки изображения";
}
} catch (error) {
errorMessage = handleError(error);
} finally {
setLoading(false);
}
}
async function handleUrlSubmit(e: Event) {
e.preventDefault();
if (!urlInput?.value.trim()) return;
try {
setLoading(true);
clearError();
const result: ImageUploadResult = await imageService.handleUrlUpload(urlInput.value.trim());
if (result.success && result.image) {
uploadedImage = result.image;
onImageSelect(result.image);
setCurrentStep("crop");
showUrlInput = false;
} else {
errorMessage = result.error || "Ошибка загрузки изображения";
}
} catch (error) {
errorMessage = handleError(error);
} finally {
setLoading(false);
}
}
function triggerFileInput() {
fileInput?.click();
}
function resetUpload() {
uploadedImage = undefined;
errorMessage = undefined;
showUrlInput = false;
setCurrentStep("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 variant="secondary" onclick={resetUpload}>Загрузить другое</Button>
</div>
</div>
{:else}
<div
bind:this={dropZone}
class="drop-zone {isDragOver ? 'drag-over' : ''} {uiError ? 'error' : ''}"
role="button"
tabindex="0"
onclick={triggerFileInput}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
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
variant="primary"
onclick={(e) => {
e.stopPropagation();
triggerFileInput();
}}>Выбрать файл</Button
>
<Button variant="secondary" onclick={() => (showUrlInput = !showUrlInput)}>
{showUrlInput ? "Скрыть" : "По URL"}
</Button>
</div>
{#if showUrlInput}
<form bind:this={urlForm} onsubmit={handleUrlSubmit} class="url-input-form">
<input
bind:this={urlInput}
type="url"
placeholder="https://example.com/image.jpg"
required
class="url-input"
/>
<Button type="submit" variant="primary" disabled={$uiStore.isLoading} loading={$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;"
onchange={(e) => {
const file = (e.target as HTMLInputElement).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);
}
}
</style>
-72
View File
@@ -1,72 +0,0 @@
<script lang="ts">
import ErrorMessage from "$components/feedback/ErrorMessage.svelte";
import AppContent from "$components/layout/AppContent.svelte";
import AppHeader from "$components/layout/AppHeader.svelte";
interface Props {
errorMessage: string | undefined;
uploadedImage: string | undefined;
texts: Array<{ id: string; text: string }>;
backgroundImage: string | undefined;
panels: any[];
onUploadNewImage: () => void;
onImageUpload: (image: string) => void;
onCropComplete: (croppedImage: string) => void;
onCropCancel: () => void;
onTextAdd: (text: string) => void;
onTextUpdate: (id: string, newText: string) => void;
onTextDelete: (id: string) => void;
onDownload: (panel: any, konvaStage: any) => Promise<void>;
onDownloadAll: () => Promise<void>;
}
let {
errorMessage,
uploadedImage,
texts,
backgroundImage,
panels,
onUploadNewImage,
onImageUpload,
onCropComplete,
onCropCancel,
onTextAdd,
onTextUpdate,
onTextDelete,
onDownload,
onDownloadAll,
}: Props = $props();
</script>
<div class="app-container">
<AppHeader />
<ErrorMessage {errorMessage} />
<AppContent
{uploadedImage}
{texts}
{backgroundImage}
{panels}
{onImageUpload}
{onCropComplete}
{onCropCancel}
{onTextAdd}
{onTextUpdate}
{onTextDelete}
{onUploadNewImage}
{onDownload}
{onDownloadAll}
/>
</div>
<style>
.app-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
</style>
-64
View File
@@ -1,64 +0,0 @@
<script lang="ts">
import MainSection from "$components/layout/MainSection.svelte";
import Sidebar from "$components/layout/Sidebar.svelte";
interface Props {
uploadedImage: string | undefined;
texts: Array<{ id: string; text: string }>;
backgroundImage: string | undefined;
panels: any[];
onImageUpload: (image: string) => void;
onCropComplete: (croppedImage: string) => void;
onCropCancel: () => void;
onTextAdd: (text: string) => void;
onTextUpdate: (id: string, newText: string) => void;
onTextDelete: (id: string) => void;
onUploadNewImage: () => void;
onDownload: (panel: any, konvaStage: any) => Promise<void>;
onDownloadAll: () => Promise<void>;
}
let {
uploadedImage,
texts,
backgroundImage,
panels,
onImageUpload,
onCropComplete,
onCropCancel,
onTextAdd,
onTextUpdate,
onTextDelete,
onUploadNewImage,
onDownload,
onDownloadAll,
}: Props = $props();
</script>
<div class="app-content">
<MainSection
{uploadedImage}
{texts}
{onImageUpload}
{onCropComplete}
{onCropCancel}
{onTextAdd}
{onTextUpdate}
{onTextDelete}
/>
<Sidebar {backgroundImage} {panels} {onUploadNewImage} {onDownload} {onDownloadAll} />
</div>
<style>
.app-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
@media (max-width: 1024px) {
.app-content {
grid-template-columns: 1fr;
}
}
</style>
+86 -30
View File
@@ -1,30 +1,86 @@
<script lang="ts">
interface Props {
onUploadNewImage?: () => void;
}
let { onUploadNewImage }: Props = $props();
</script>
<div class="app-header">
<h1>Twitch Panels Creator</h1>
</div>
<style>
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
color: white;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.app-header h1 {
margin: 0;
font-size: 2rem;
font-weight: 600;
}
</style>
<script lang="ts">
import { themeState } from "$states/theme.svelte";
import Moon from "~icons/lucide/moon";
import Sun from "~icons/lucide/sun";
function toggleTheme() {
themeState.toggle();
}
</script>
<header class="header">
<div class="header-content">
<h1>Twitch Panels</h1>
<button class="theme-toggle" aria-label="Toggle theme" onclick={toggleTheme}>
<Sun class="sun-icon" />
<Moon class="moon-icon" />
</button>
</div>
</header>
<style>
.header {
background: linear-gradient(135deg, var(--action-primary) 0%, var(--action-secondary) 100%);
color: var(--text-action);
padding: 16px 20px;
border-radius: var(--radius);
margin-bottom: 16px;
box-shadow: var(--shadow-md);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-size: 20px;
font-weight: 600;
}
.theme-toggle {
width: 36px;
height: 36px;
border-radius: 6px;
border: none;
background: rgba(255, 255, 255, 0.2);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition);
position: relative;
}
.theme-toggle:hover {
background: rgba(255, 255, 255, 0.3);
}
:global(.theme-toggle svg) {
width: 18px;
height: 18px;
position: absolute;
transition: var(--transition);
}
:global(.sun-icon) {
opacity: 1;
transform: rotate(0deg);
}
:global(.moon-icon) {
opacity: 0;
transform: rotate(90deg);
}
:global([data-theme="dark"] .sun-icon) {
opacity: 0;
transform: rotate(90deg);
}
:global([data-theme="dark"] .moon-icon) {
opacity: 1;
transform: rotate(0deg);
}
</style>
+67
View File
@@ -0,0 +1,67 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
title: string | Snippet;
children: Snippet;
titleSnippet?: Snippet;
}
let { title, children, titleSnippet = emptySnippet }: Props = $props();
</script>
{#snippet emptySnippet()}{/snippet}
<section class="card">
<div class="card-header-row">
<h2 class="card-title">
{#if typeof title === "string"}
{title}
{:else}
{@render title()}
{/if}
</h2>
<div class="card-snippet">
{@render titleSnippet()}
</div>
</div>
<div class="card-body">
{@render children()}
</div>
</section>
<style>
.card {
background: var(--surface-elevated);
border: 1px solid var(--border-main);
border-radius: var(--radius);
padding: 16px;
margin-bottom: 16px;
transition: var(--transition);
overflow: hidden;
box-shadow: var(--shadow);
}
.card-title {
font-size: 15px;
font-weight: 600;
color: var(--text-main);
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.card-header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.card-snippet {
display: flex;
align-items: center;
gap: 8px;
}
</style>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
}
let { children }: Props = $props();
</script>
<div class="input-group">
{@render children()}
</div>
<style>
.input-group {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
</style>
-42
View File
@@ -1,42 +0,0 @@
<script lang="ts">
import ImageManager from "$components/image/ImageManager.svelte";
import TextSection from "$components/text/TextSection.svelte";
import { uiStore } from "$stores/uiStore";
interface Props {
uploadedImage: string | undefined;
texts: Array<{ id: string; text: string }>;
onImageUpload: (image: string) => void;
onCropComplete: (croppedImage: string) => void;
onCropCancel: () => void;
onTextAdd: (text: string) => void;
onTextUpdate: (id: string, newText: string) => void;
onTextDelete: (id: string) => void;
}
let {
uploadedImage,
texts,
onImageUpload,
onCropComplete,
onCropCancel,
onTextAdd,
onTextUpdate,
onTextDelete,
}: Props = $props();
</script>
<div class="main-section">
<ImageManager currentStep={$uiStore.currentStep} {uploadedImage} {onImageUpload} {onCropComplete} {onCropCancel} />
{#if $uiStore.currentStep === "text"}
<TextSection {texts} {onTextAdd} {onTextUpdate} {onTextDelete} />
{/if}
</div>
<style>
.main-section {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
</style>
+9
View File
@@ -0,0 +1,9 @@
<script lang="ts">
import ImageManager from "$components/image/ImageManager.svelte";
import PreviewManager from "$components/panel/PreviewManager.svelte";
</script>
<div class="panel-bar">
<ImageManager />
<PreviewManager />
</div>
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts">
import Button from "$components/ui/Button.svelte";
import type { Component, Snippet } from "svelte";
interface Props {
children: Snippet;
label: string;
icon: Component;
}
let { children, label = "fuf", icon }: Props = $props();
let expanded = $state(false);
</script>
<div class="settings-grid">
<label>
{label}
<Button {icon} ariaLabel="Expand" type="mini" onclick={() => (expanded = !expanded)} />
</label>
{#if expanded}
{@render children()}
{/if}
</div>
<style>
.settings-grid {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
background: var(--surface-subtle);
border-radius: var(--radius);
border: 1px solid var(--border-main);
}
label {
color: var(--text-main);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 16px;
cursor: pointer;
user-select: none;
}
</style>
+41
View File
@@ -0,0 +1,41 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
label: string;
children: Snippet;
noLabel?: boolean;
}
let { label, children, noLabel = false }: Props = $props();
let tag = $derived(noLabel ? "div" : "label");
</script>
<svelte:element this={tag} class="setting-row">
<div class="setting-label">{label}</div>
<div class="setting-control">
{@render children()}
</div>
</svelte:element>
<style>
.setting-row {
display: grid;
grid-template-columns: 100px 1fr;
align-items: center;
gap: 12px;
}
.setting-label {
color: var(--text-muted);
font-size: 13px;
font-weight: 500;
}
.setting-control {
display: flex;
align-items: center;
gap: 10px;
}
</style>
-36
View File
@@ -1,36 +0,0 @@
<script lang="ts">
import BackgroundPreview from "$components/image/BackgroundPreview.svelte";
import PanelsList from "$components/panel/PanelsList.svelte";
import { uiStore } from "$stores/uiStore";
interface Props {
backgroundImage: string | undefined;
panels: any[];
onUploadNewImage: () => void;
onDownload: (panel: any, konvaStage: any) => Promise<void>;
onDownloadAll: () => Promise<void>;
}
let { backgroundImage, panels, onUploadNewImage, onDownload, onDownloadAll }: Props = $props();
</script>
<div class="sidebar">
{#if $uiStore.currentStep === "text"}
<BackgroundPreview {backgroundImage} {onUploadNewImage} />
<PanelsList {panels} {onDownload} {onDownloadAll} />
{/if}
</div>
<style>
.sidebar {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
@media (max-width: 1024px) {
.sidebar {
order: -1;
}
}
</style>
+9
View File
@@ -0,0 +1,9 @@
<script lang="ts">
import TextConfig from "$components/text/TextConfig.svelte";
import TextManager from "$components/text/TextManager.svelte";
</script>
<div class="text-bar">
<TextManager />
<TextConfig />
</div>
-173
View File
@@ -1,173 +0,0 @@
<script lang="ts">
import Button from "$components/ui/Button.svelte";
import { PANEL_SETTINGS, TYPOGRAPHY, UI_SETTINGS } from "$lib/constants";
import type { Panel, TextItem } from "$lib/types/panel";
import { setCurrentStep } from "$stores/uiStore";
import type { Stage as KonvaStage } from "konva/lib/Stage";
import { Image, Layer, Stage, Text } from "svelte-konva";
interface Props {
panel: Panel;
onDownload?: (panel: Panel, konvaStage: KonvaStage) => void;
}
let { panel, onDownload }: Props = $props();
let backgroundImage: HTMLImageElement | undefined = $state(undefined);
let konvaStage: KonvaStage | null = $state(null);
let stageComponent: any = $state(null);
$effect(() => {
if (stageComponent) {
konvaStage = stageComponent.node;
}
});
function handleUploadNewImage() {
setCurrentStep(UI_SETTINGS.STEPS.UPLOAD);
}
$effect(() => {
if (panel?.backgroundImage && panel.backgroundImage !== backgroundImage?.src) {
loadImage(panel.backgroundImage);
}
});
function loadImage(src: string) {
const img = document.createElement("img");
img.onload = () => {
backgroundImage = img;
};
img.onerror = (error) => {
console.error("[PanelPreview] Failed to load background image");
};
img.src = src;
}
function handleDownload() {
let stageToUse = konvaStage;
if (!stageToUse && stageComponent) {
stageToUse = stageComponent.node || stageComponent.stage || stageComponent._stage || stageComponent;
if (stageToUse && typeof stageToUse.toBlob === "function") {
} else {
if (typeof stageComponent.toBlob === "function") {
stageToUse = stageComponent;
}
}
}
if (onDownload && stageToUse) {
onDownload(panel, stageToUse);
}
}
function getTextPosition(textItem: TextItem) {
const panelWidth = PANEL_SETTINGS.PANEL_WIDTH;
const paddingX = textItem.paddingX ?? 20;
const verticalOffset = textItem.verticalOffset ?? 0;
const centerY = panel.height / 2 + verticalOffset;
const textWidth = 300;
let position;
switch (textItem.textAlign) {
case "left":
position = { x: paddingX, y: centerY };
break;
case "right":
position = { x: panelWidth - textWidth - paddingX, y: centerY };
break;
case "center":
default:
position = { x: (panelWidth - textWidth) / 2, y: centerY };
break;
}
return position;
}
</script>
<div class="panel-preview">
<div class="preview-header">
<div class="panel-title">{panel.text?.text || "Без названия"}</div>
<Button variant="primary" size="sm" onclick={handleDownload}>Скачать</Button>
</div>
<div class="preview-sections">
<div class="preview-section">
<div class="canvas-container">
<Stage width={PANEL_SETTINGS.PANEL_WIDTH} height={panel.height} bind:this={stageComponent}>
<Layer>
{#if backgroundImage}
<Image image={backgroundImage} width={PANEL_SETTINGS.PANEL_WIDTH} height={panel.height} />
{/if}
</Layer>
<Layer>
{#if panel.text}
{@const textPosition = getTextPosition(panel.text)}
<Text
text={panel.text.text}
fontSize={panel.text.fontSize || TYPOGRAPHY.FONT_SIZE_DEFAULT}
fill={panel.text.color || TYPOGRAPHY.TEXT_COLOR_DEFAULT}
fontFamily={panel.text.fontFamily || "Arial"}
x={textPosition.x}
y={textPosition.y}
align={panel.text.textAlign || TYPOGRAPHY.TEXT_ALIGN_CENTER}
width={PANEL_SETTINGS.PANEL_WIDTH - textPosition.x * 2}
offsetX={0}
/>
{/if}
</Layer>
</Stage>
</div>
</div>
</div>
</div>
<style>
.panel-preview {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
background: #f8f9fa;
border-radius: 8px;
}
.panel-title {
font-weight: 600;
color: #333;
font-size: 1.1rem;
}
.preview-sections {
display: flex;
flex-direction: column;
gap: 1rem;
}
.preview-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.canvas-container {
display: flex;
justify-content: center;
padding: 1rem;
background: #f5f5f5;
border-radius: 8px;
}
</style>
-66
View File
@@ -1,66 +0,0 @@
<script lang="ts">
import type { Panel } from "$lib/types/panel";
import type { Stage as KonvaStage } from "konva/lib/Stage";
import Button from "../ui/Button.svelte";
import PanelPreview from "./PanelPreview.svelte";
interface Props {
panels: Panel[];
onDownload: (panel: Panel, konvaStage: KonvaStage) => void;
onDownloadAll: () => void;
}
let { panels, onDownload, onDownloadAll }: Props = $props();
</script>
{#if panels.length > 0}
<div class="panels-container">
<div class="panels-header">
<h2>Созданные панели ({panels.length})</h2>
<Button variant="primary" onclick={onDownloadAll}>Скачать все</Button>
</div>
<div class="panels-list">
{#each panels as panel (panel.id)}
<div class="panel-item">
<PanelPreview {panel} onDownload={(panel, konvaStage) => onDownload(panel, konvaStage)} />
</div>
{/each}
</div>
</div>
{/if}
<style>
.panels-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.panels-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
color: white;
}
.panels-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.panels-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.panel-item {
border: 2px solid #e9ecef;
border-radius: 8px;
overflow: hidden;
background: white;
}
</style>
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
import { PANEL_SETTINGS } from "$lib/constants";
import { imageState } from "$states/image.svelte";
import { textConfigState } from "$states/textConfig.svelte";
import { Image, Layer, Stage, Text } from "svelte-konva";
interface Props {
text: string;
stage: Stage | undefined;
}
let { text, stage = $bindable() }: Props = $props();
let x = $derived(textConfigState.paddingX);
let y = $derived(textConfigState.offsetY);
let width = $derived(PANEL_SETTINGS.PANEL_WIDTH - 2 * textConfigState.paddingX);
</script>
<Stage
width={PANEL_SETTINGS.PANEL_WIDTH}
height={PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT}
bind:this={stage}
>
<Layer>
<Image image={imageState.masterImage} listening={false} />
<Text
{text}
{x}
{y}
{width}
fontSize={textConfigState.fontSize}
stroke={textConfigState.outlined ? textConfigState.color : "transparent"}
fill={textConfigState.outlined ? "transparent" : textConfigState.color}
fontFamily={textConfigState.fontFamily}
align={textConfigState.align}
/>
</Layer>
</Stage>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import { konvaAllStagesState } from "$states/konvaAllStages.svelte";
import { textsState } from "$states/texts.svelte";
import Preview from "./Preview.svelte";
</script>
<div class="outside-display">
{#each textsState.texts as { text, id }, idx (id)}
<Preview {text} bind:stage={konvaAllStagesState[idx]} />
{:else}
<p>No texts to preview</p>
{/each}
</div>
<style>
.outside-display {
position: fixed;
top: -1000px;
left: -1000px;
}
</style>
@@ -0,0 +1,46 @@
<script lang="ts">
import Button from "$components/ui/Button.svelte";
import { SlideDirection, type SlideDirectionType } from "$lib/constants";
import ChevronLeft from "~icons/lucide/chevron-left";
import ChevronRight from "~icons/lucide/chevron-right";
interface Props {
current: number;
direction: SlideDirectionType;
max: number;
}
let { current = $bindable(), direction = $bindable(SlideDirection.NEXT), max }: Props = $props();
let isFirst = $derived(current === 0);
let isLast = $derived(current === max - 1);
function prev() {
if (current > 0) current--;
direction = SlideDirection.PREV;
}
async function next() {
if (current < max - 1) current++;
direction = SlideDirection.NEXT;
}
</script>
<Button
icon={ChevronLeft}
ariaLabel="Previous slide"
type="mini"
onclick={prev}
disabled={isFirst}
/>
<span class="panel-indicator">{current + 1} / {max}</span>
<Button icon={ChevronRight} ariaLabel="Next slide" type="mini" onclick={next} disabled={isLast} />
<style>
.panel-indicator {
font-size: 13px;
font-weight: 500;
min-width: 50px;
text-align: center;
}
</style>
+174
View File
@@ -0,0 +1,174 @@
<script lang="ts">
import Card from "$components/layout/Card.svelte";
import Badge from "$components/ui/Badge.svelte";
import Button from "$components/ui/Button.svelte";
import { PANEL_SETTINGS, TRANSITION_DURATION, type SlideDirectionType } from "$lib/constants";
import { downloadService, type DownloadItem } from "$services/downloadService";
import { imageState } from "$states/image.svelte";
import { konvaAllStagesState } from "$states/konvaAllStages.svelte";
import { konvaStageState } from "$states/konvaStage.svelte";
import { textsState } from "$states/texts.svelte";
import { fly } from "svelte/transition";
import Download from "~icons/lucide/download";
import Loader from "~icons/lucide/loader";
import StickyNote from "~icons/lucide/sticky-note";
import Preview from "./Preview.svelte";
import PreviewAll from "./PreviewAll.svelte";
import PreviewControls from "./PreviewControls.svelte";
let current: number = $state(0);
let direction: SlideDirectionType = $state("next");
let xDirection = $derived(
direction == "next" ? PANEL_SETTINGS.PANEL_WIDTH : -PANEL_SETTINGS.PANEL_WIDTH,
);
$effect(() => {
if (textsState.texts.length === 0) {
current = 0;
} else {
if (current > textsState.texts.length - 1) {
current = textsState.texts.length - 1;
}
}
});
function downloadAll() {
let downloadItems: Array<DownloadItem> = textsState.texts.map((text, idx) => ({
filename: text.text,
stage: konvaAllStagesState[idx]?.node,
}));
downloadService.downloadAll(downloadItems);
}
function downloadCurrent() {
let node = konvaStageState.stage?.node;
if (node) {
downloadService.downloadPanel(node, textsState.texts[current].text);
}
}
</script>
{#snippet panelTitle()}
Панели <Badge text={textsState.texts.length} />
{/snippet}
{#snippet panelControls()}
<PreviewControls bind:current bind:direction max={textsState.texts.length} />
{/snippet}
<Card title={panelTitle} titleSnippet={panelControls}>
<div class="panel-viewer">
<div class="panel-display">
{#if textsState.texts.length}
{#key current}
{@const text = textsState?.texts[current]?.text}
<div
class="konva-wrapper"
in:fly={{ x: xDirection, duration: TRANSITION_DURATION }}
out:fly={{ x: -xDirection, duration: TRANSITION_DURATION }}
>
<div class="loader" class:loading={imageState.isReady === false}>
<Loader />
</div>
<Preview {text} bind:stage={konvaStageState.stage} />
</div>
{/key}
{:else}
<div class="empty-state" aria-label="Empty texts info">
<StickyNote />
<p>Добавьте тексты для создания панелей</p>
</div>
{/if}
</div>
<div class="panel-actions">
<Button label="Скачать всё" ariaLabel="Download all" icon={Download} onclick={downloadAll} />
<Button
label="Скачать"
ariaLabel="Download current"
type="secondary"
icon={Download}
onclick={downloadCurrent}
/>
</div>
</div>
</Card>
<PreviewAll />
<style>
.panel-viewer {
display: flex;
flex-direction: column;
gap: 12px;
}
.panel-display {
background: var(--surface-subtle);
border-radius: var(--radius);
padding: 20px;
display: flex;
justify-content: center;
align-items: center;
min-height: 150px;
position: relative;
}
.panel-actions {
display: flex;
gap: 8px;
}
.empty-state {
text-align: center;
padding: 32px 16px;
}
.empty-state :global(svg) {
width: 48px;
height: 48px;
margin: 0 auto 12px;
opacity: 0.5;
}
.empty-state p {
font-size: 14px;
}
.konva-wrapper {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.loader {
position: absolute;
top: 0;
left: 0;
width: 32px;
height: 32px;
display: flex;
justify-content: center;
align-items: center;
transition: 0.3s;
opacity: 0;
&.loading {
opacity: 1;
animation: spin 3s linear infinite;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
+52
View File
@@ -0,0 +1,52 @@
<script lang="ts">
import Card from "$components/layout/Card.svelte";
import SettingsGrid from "$components/layout/SettingsGrid.svelte";
import SettingsRow from "$components/layout/SettingsRow.svelte";
import Alignment from "$components/ui/Alignment.svelte";
import ColorPicker from "$components/ui/ColorPicker.svelte";
import Outline from "$components/ui/Outline.svelte";
import RangeSlider from "$components/ui/RangeSlider.svelte";
import SelectFont from "$components/ui/SelectFont.svelte";
import { TYPOGRAPHY } from "$lib/constants";
import { textConfigState } from "$states/textConfig.svelte";
import TextSettings from "~icons/lucide/text-initial";
</script>
<Card title="Настройки текста">
<SettingsGrid label="Настройки шрифта" icon={TextSettings}>
<SettingsRow label="Размер">
<RangeSlider
bind:value={textConfigState.fontSize}
min={TYPOGRAPHY.FONT_SIZE_MIN}
max={TYPOGRAPHY.FONT_SIZE_MAX}
step={1}
/>
</SettingsRow>
<SettingsRow label="Шрифт">
<SelectFont bind:value={textConfigState.fontFamily} />
</SettingsRow>
<SettingsRow label="Цвет" noLabel={true}>
<ColorPicker bind:value={textConfigState.color} />
<Outline bind:outlined={textConfigState.outlined} />
</SettingsRow>
<SettingsRow label="Выравнивание" noLabel={true}>
<Alignment bind:align={textConfigState.align} />
</SettingsRow>
<SettingsRow label="Отступы">
<RangeSlider
bind:value={textConfigState.paddingX}
min={TYPOGRAPHY.PADDING_X_MIN}
max={TYPOGRAPHY.PADDING_X_MAX}
step={1}
/>
</SettingsRow>
<SettingsRow label="Смещение">
<RangeSlider
bind:value={textConfigState.offsetY}
min={TYPOGRAPHY.OFFSET_Y_MIN}
max={TYPOGRAPHY.OFFSET_Y_MAX}
step={1}
/>
</SettingsRow>
</SettingsGrid>
</Card>
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
import Button from "$components/ui/Button.svelte";
import { TRANSITION_DURATION } from "$lib/constants";
import { fly } from "svelte/transition";
import Cross from "~icons/lucide/x";
interface Props {
text: string;
id: number;
ondelete: (id: number) => void;
}
let { text = $bindable(), id, ondelete }: Props = $props();
</script>
<li class="text-item" transition:fly={{ x: 600, duration: TRANSITION_DURATION }}>
<input type="text" bind:value={text} />
<Button icon={Cross} ariaLabel="Delete" type="danger" onclick={() => ondelete(id)} />
</li>
<style>
.text-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: var(--surface-subtle);
border: 1px solid var(--border-main);
border-radius: var(--radius);
transition: var(--transition);
}
.text-item:hover {
border-color: var(--border-strong);
}
.text-item input {
flex: 1;
padding: 6px 10px;
border: 1px solid var(--border-main);
border-radius: 4px;
font-size: 14px;
background: var(--surface-main);
color: var(--text-main);
transition: var(--transition);
font-family: inherit;
}
.text-item input:focus {
outline: none;
border-color: var(--border-strong);
}
</style>
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts">
interface Props {
text: string;
ariaLabel: string;
onenter: () => void;
}
let { text = $bindable(), onenter, ariaLabel }: Props = $props();
function handleKeyboard(event: KeyboardEvent) {
if (event.key === "Enter") {
onenter();
}
}
</script>
<input
aria-label={ariaLabel}
bind:value={text}
class="text-input"
type="text"
placeholder="Введите текст..."
onkeydown={handleKeyboard}
/>
<style>
.text-input {
flex: 1;
padding: 10px 12px;
border: 1px solid var(--border-main);
border-radius: var(--radius);
font-size: 14px;
background: var(--surface-main);
color: var(--text-main);
transition: var(--transition);
font-family: inherit;
}
.text-input:focus {
outline: none;
border-color: var(--border-strong);
}
.text-input::placeholder {
color: var(--text-muted);
}
</style>
+40 -414
View File
@@ -1,414 +1,40 @@
<script lang="ts">
import Button from "$components/ui/Button.svelte";
import { TYPOGRAPHY } from "$lib/constants";
import type { TextAlign, TextItem } from "$lib/types/panel";
import { textSettingsStore, updateAllTextSettings } from "$stores/panelStore";
interface Props {
onTextAdd: (text: string, settings?: Partial<TextItem>) => void;
onTextUpdate: (id: string, text: string) => void;
onTextDelete: (id: string) => void;
texts: Array<{ id: string; text: string }>;
}
let { onTextAdd, onTextUpdate, onTextDelete, texts }: Props = $props();
let newText = $state("");
let errorMessage = $state<string | undefined>(undefined);
let commonTextSettings = $derived($textSettingsStore);
const availableFonts = TYPOGRAPHY.FONT_FAMILIES;
function handleAddText() {
if (!newText.trim()) {
errorMessage = "Введите текст";
return;
}
if (newText.length > TYPOGRAPHY.MAX_TEXT_LENGTH) {
errorMessage = `Текст не должен превышать ${TYPOGRAPHY.MAX_TEXT_LENGTH} символов`;
return;
}
try {
errorMessage = undefined;
onTextAdd(newText.trim(), commonTextSettings);
newText = "";
} catch (error) {
errorMessage = error instanceof Error ? error.message : "Ошибка добавления текста";
}
}
function handleKeyPress(e: KeyboardEvent) {
if (e.key === "Enter") {
handleAddText();
}
}
function handleUpdateText(id: string, newText: string) {
if (onTextUpdate && newText.trim()) {
onTextUpdate(id, newText.trim());
}
}
function handleDeleteText(id: string) {
if (onTextDelete) {
onTextDelete(id);
}
}
</script>
<div class="text-manager">
<div class="add-text-section">
<div class="input-group">
<input
type="text"
bind:value={newText}
placeholder="Введите текст для панели (например: links, about me, projects...)"
class="text-input"
maxlength={TYPOGRAPHY.MAX_TEXT_LENGTH}
onkeypress={handleKeyPress}
/>
<Button variant="primary" onclick={handleAddText}>Добавить</Button>
</div>
{#if errorMessage}
<div class="error-message">
{errorMessage}
</div>
{/if}
</div>
{#if texts.length > 0}
<div class="texts-list">
<h3>Созданные тексты ({texts.length})</h3>
<div class="texts-container">
{#each texts as textItem (textItem.id)}
<div class="text-item">
<input
type="text"
value={textItem.text}
oninput={(e) => handleUpdateText(textItem.id, e.currentTarget.value)}
class="text-edit-input"
maxlength={TYPOGRAPHY.MAX_TEXT_LENGTH}
/>
<Button variant="danger" size="sm" onclick={() => handleDeleteText(textItem.id)} aria-label="Удалить текст">
×
</Button>
</div>
{/each}
</div>
</div>
{/if}
<div class="common-settings-section">
<h3>Общие настройки текста</h3>
<p class="settings-note">Настройки применятся ко всем создаваемым панелям</p>
<div class="settings-grid">
<div class="control-group">
<label>
Размер шрифта:
<input
type="range"
min={TYPOGRAPHY.FONT_SIZE_MIN}
max={TYPOGRAPHY.FONT_SIZE_MAX}
step="1"
value={commonTextSettings.fontSize}
oninput={(e: Event) => {
updateAllTextSettings({ fontSize: parseInt((e.target as HTMLInputElement).value) });
}}
/>
<span class="value-display">{commonTextSettings.fontSize}px</span>
</label>
</div>
<div class="control-group">
<label>
Шрифт:
<select
value={commonTextSettings.fontFamily}
onchange={(e) => {
updateAllTextSettings({ fontFamily: (e.target as HTMLSelectElement).value });
}}
>
{#each availableFonts as font}
<option value={font}>{font}</option>
{/each}
</select>
</label>
</div>
<div class="control-group">
<label>
Цвет:
<input
type="color"
value={commonTextSettings.color}
oninput={(e) => {
updateAllTextSettings({ color: (e.target as HTMLInputElement).value });
}}
/>
</label>
</div>
<div class="control-group">
<label>
Выравнивание:
<div class="alignment-buttons">
<button
class="align-btn {commonTextSettings.textAlign === 'left' ? 'active' : ''}"
onclick={() => {
updateAllTextSettings({ textAlign: "left" as TextAlign });
}}
aria-label="Выровнять по левому краю"
>
</button>
<button
class="align-btn {commonTextSettings.textAlign === 'center' ? 'active' : ''}"
onclick={() => {
updateAllTextSettings({ textAlign: "center" as TextAlign });
}}
aria-label="Выровнять по центру"
>
</button>
<button
class="align-btn {commonTextSettings.textAlign === 'right' ? 'active' : ''}"
onclick={() => {
updateAllTextSettings({ textAlign: "right" as TextAlign });
}}
aria-label="Выровнять по правому краю"
>
</button>
</div>
</label>
</div>
<div class="control-group">
<label>
Боковые отступы:
<input
type="range"
min="0"
max={TYPOGRAPHY.PADDING_X_MAX}
step="1"
value={commonTextSettings.paddingX}
oninput={(e) => {
updateAllTextSettings({ paddingX: parseInt((e.target as HTMLInputElement).value) });
}}
/>
<span class="value-display">{commonTextSettings.paddingX}px</span>
</label>
</div>
<div class="control-group">
<label>
Смещение от центра:
<input
type="range"
min={TYPOGRAPHY.VERTICAL_OFFSET_MIN}
max={TYPOGRAPHY.VERTICAL_OFFSET_MAX}
step="1"
value={commonTextSettings.verticalOffset}
oninput={(e) => {
updateAllTextSettings({ verticalOffset: parseInt((e.target as HTMLInputElement).value) });
}}
/>
<span class="value-display"
>{(commonTextSettings.verticalOffset ?? 0) > 0 ? "+" : ""}{commonTextSettings.verticalOffset ?? 0}px</span
>
</label>
</div>
</div>
</div>
</div>
<style>
.text-manager {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
}
.common-settings-section {
background: #f8f9fa;
border: 2px solid #e9ecef;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.common-settings-section h3 {
margin: 0 0 0.75rem 0;
color: #333;
font-size: 1rem;
font-weight: 500;
}
.settings-note {
margin: 0 0 1rem 0;
color: #666;
font-size: 0.875rem;
font-style: italic;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
.alignment-buttons {
display: flex;
gap: 0.25rem;
}
.align-btn {
padding: 0.5rem 0.75rem;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 1rem;
}
.align-btn:hover {
background: #f0f0f0;
border-color: #007bff;
}
.align-btn.active {
background: #007bff;
color: white;
border-color: #007bff;
}
.add-text-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.texts-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.texts-list h3 {
margin: 0;
color: #333;
font-size: 1rem;
font-weight: 600;
}
.texts-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.text-item {
display: flex;
gap: 0.5rem;
align-items: center;
}
.text-edit-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.95rem;
}
.text-edit-input:focus {
outline: none;
border-color: #007bff;
}
.input-group {
display: flex;
gap: 0.5rem;
}
.text-input {
flex: 1;
padding: 0.6rem 0.75rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 0.95rem;
}
.text-input:focus {
outline: none;
border-color: #007bff;
}
.error-message {
background: #ffebee;
color: #c62828;
padding: 0.6rem 0.75rem;
border-radius: 6px;
border: 1px solid #ffcdd2;
font-size: 0.9rem;
}
.text-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
border: 2px solid #e9ecef;
border-radius: 6px;
background: white;
transition: all 0.2s ease;
}
.text-item:hover {
border-color: #007bff;
background: #f0f8ff;
}
.control-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.control-group label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: #666;
}
.control-group input[type="range"] {
flex: 1;
cursor: pointer;
}
.control-group input[type="color"] {
width: 40px;
height: 40px;
border: none;
border-radius: 4px;
cursor: pointer;
padding: 0;
}
.value-display {
min-width: 40px;
text-align: right;
font-weight: 500;
color: #333;
}
</style>
<script lang="ts">
import Card from "$components/layout/Card.svelte";
import InputGroup from "$components/layout/InputGroup.svelte";
import Button from "$components/ui/Button.svelte";
import { textsState } from "$states/texts.svelte";
import Plus from "~icons/lucide/plus";
import TextInlineEdit from "./TextInlineEdit.svelte";
import TextInput from "./TextInput.svelte";
let text: string = $state("");
function addText() {
textsState.addText(text);
text = "";
}
function deleteText(id: number) {
textsState.removeText(id);
}
</script>
<Card title="Тексты панелей">
<InputGroup>
<TextInput ariaLabel="Input new text" bind:text onenter={addText} />
<Button icon={Plus} ariaLabel="Add text" onclick={addText} />
</InputGroup>
<ul class="texts-list">
{#each textsState.texts as { id }, idx (id)}
<TextInlineEdit {id} bind:text={textsState.texts[idx].text} ondelete={() => deleteText(id)} />
{/each}
</ul>
</Card>
<style>
.texts-list {
display: flex;
flex-direction: column;
gap: 8px;
}
</style>
-33
View File
@@ -1,33 +0,0 @@
<script lang="ts">
import type { TextItem } from "$lib/types/panel";
import TextManager from "./TextManager.svelte";
interface Props {
texts: Array<{ id: string; text: string }>;
onTextAdd: (text: string, settings?: Partial<TextItem>) => void;
onTextUpdate: (id: string, text: string) => void;
onTextDelete: (id: string) => void;
}
let { texts, onTextAdd, onTextUpdate, onTextDelete }: Props = $props();
</script>
<div class="text-section">
<h2>Добавьте тексты для панелей</h2>
<TextManager {onTextAdd} {onTextUpdate} {onTextDelete} {texts} />
</div>
<style>
.text-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.text-section h2 {
margin: 0;
color: #333;
font-size: 1.5rem;
font-weight: 600;
}
</style>
+99
View File
@@ -0,0 +1,99 @@
<script lang="ts">
import { TextAlign, type TextAlignType } from "$lib/constants";
import TextAlignCenter from "~icons/lucide/text-align-center";
import TextAlignEnd from "~icons/lucide/text-align-end";
import TextAlignStart from "~icons/lucide/text-align-start";
interface Props {
align: TextAlignType;
}
let { align = $bindable(TextAlign.LEFT) }: Props = $props();
</script>
<div class="alignment-group">
<label class="alignment-item">
<input
type="radio"
name="alignment"
value={TextAlign.LEFT}
class="sr-only"
bind:group={align}
aria-label={`Set ${TextAlign.LEFT}`}
/>
<div class="alignment-button"><TextAlignStart /></div>
</label>
<label class="alignment-item">
<input
type="radio"
name="alignment"
value={TextAlign.CENTER}
class="sr-only"
bind:group={align}
aria-label={`Set ${TextAlign.CENTER}`}
/>
<div class="alignment-button"><TextAlignCenter /></div>
</label>
<label class="alignment-item">
<input
type="radio"
name="alignment"
value={TextAlign.RIGHT}
class="sr-only"
bind:group={align}
aria-label={`Set ${TextAlign.RIGHT}`}
/>
<div class="alignment-button"><TextAlignEnd /></div>
</label>
</div>
<style>
.alignment-group {
display: flex;
overflow: hidden;
width: 100%;
}
.alignment-button {
flex-grow: 1;
border: 1px solid var(--border-strong);
padding: 6px 12px;
cursor: pointer;
color: var(--text-muted);
transition: all 0.2s ease;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.alignment-button:hover {
color: var(--text-main);
background: var(--surface-subtle);
}
.alignment-item {
flex-grow: 1;
& input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
&:first-child .alignment-button {
border-top-left-radius: var(--radius);
border-bottom-left-radius: var(--radius);
}
&:last-child .alignment-button {
border-top-right-radius: var(--radius);
border-bottom-right-radius: var(--radius);
}
}
.alignment-item input:checked + .alignment-button {
background: var(--action-primary);
color: var(--text-action);
}
</style>
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
interface Props {
text: string | number;
}
let { text }: Props = $props();
</script>
<span class="badge">{text}</span>
<style>
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
background: var(--action-primary);
color: var(--text-action);
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
</style>
+115 -142
View File
@@ -1,142 +1,115 @@
<script lang="ts">
type ButtonVariant = "primary" | "secondary" | "danger";
type ButtonSize = "sm" | "md" | "lg";
type ButtonType = "button" | "submit" | "reset";
interface Props {
variant?: ButtonVariant;
size?: ButtonSize;
disabled?: boolean;
type?: ButtonType;
fullWidth?: boolean;
loading?: boolean;
class?: string;
children?: any;
onclick?: (event: MouseEvent) => void;
[key: string]: any;
}
let {
variant = "primary",
size = "md",
disabled = false,
type = "button",
fullWidth = false,
loading = false,
class: className = "",
children,
...restProps
}: Props = $props();
</script>
<button
{type}
{disabled}
class:btn-primary={variant === "primary"}
class:btn-secondary={variant === "secondary"}
class:btn-danger={variant === "danger"}
class:btn-sm={size === "sm"}
class:btn-md={size === "md"}
class:btn-lg={size === "lg"}
class:btn-full={fullWidth}
class:btn-loading={loading}
class={className}
{...restProps}
>
{#if loading}
<span class="btn-spinner"></span>
{/if}
{@render children?.()}
</button>
<style>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
white-space: nowrap;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Sizes */
.btn-sm {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
.btn-md {
padding: 0.75rem 1.5rem;
font-size: 1rem;
}
.btn-lg {
padding: 1rem 2rem;
font-size: 1.125rem;
}
.btn-full {
width: 100%;
}
/* Variants */
.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-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #c82333;
}
/* Loading state */
.btn-loading {
position: relative;
pointer-events: none;
}
.btn-spinner {
display: inline-block;
width: 1rem;
height: 1rem;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
<script lang="ts">
import type { Component } from "svelte";
import type { MouseEventHandler } from "svelte/elements";
interface Props {
icon: Component;
ariaLabel: string;
onclick?: MouseEventHandler<HTMLButtonElement>;
disabled?: boolean;
label?: string;
type?: "primary" | "secondary" | "danger" | "outline" | "mini";
extra?: "grow";
}
let {
icon: Icon,
ariaLabel,
onclick = () => {},
disabled = false,
label = "",
type = "primary",
extra,
}: Props = $props();
</script>
<button
class="btn"
class:btn-primary={type === "primary"}
class:btn-secondary={type === "secondary"}
class:btn-outline={type === "outline" || type === "mini"}
class:btn-danger={type === "danger"}
class:btn-mini={type === "mini"}
class:grow={extra === "grow"}
{disabled}
{onclick}
aria-label={ariaLabel}
>
<Icon />
{label}
</button>
<style>
.btn {
--btn-main: var(--action-primary);
--btn-hover: var(--action-primary-hover);
padding: 10px 16px;
border: 1px solid var(--btn-main);
border-radius: var(--radius);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: var(--transition);
display: inline-flex;
align-items: center;
gap: 6px;
font-family: inherit;
white-space: nowrap;
background-color: var(--btn-main);
color: var(--text-action);
}
.btn :global(svg) {
width: 16px;
height: 16px;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn:hover:not(:disabled) {
background-color: var(--btn-hover);
box-shadow: var(--shadow-md);
}
.btn-secondary {
--btn-main: var(--action-secondary);
--btn-hover: var(--action-secondary-hover);
}
.btn-mini {
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--border-main);
border-radius: var(--radius);
justify-content: center;
}
.btn-mini:hover:not(:disabled) {
border-color: var(--border-main);
}
.btn-outline {
background: transparent;
color: var(--text-main);
border: 1px solid var(--border-strong);
}
.btn-outline:hover:not(:disabled) {
background: var(--surface-hover);
border-color: var(--border-strong);
color: var(--text-main);
}
.btn-danger {
--btn-main: var(--danger-base);
}
.grow {
flex-grow: 1;
justify-content: center;
}
</style>
+40
View File
@@ -0,0 +1,40 @@
<script lang="ts">
import type { HexColor } from "$lib/types";
interface Props {
value: HexColor;
}
let { value = $bindable() }: Props = $props();
</script>
<input type="color" role="button" bind:value class="color-input" />
<span class="color-value">{value}</span>
<style>
.color-input {
width: 40px;
height: 32px;
border: 1px solid var(--border-main);
border-radius: var(--radius);
cursor: pointer;
transition: var(--transition);
background: var(--surface-main);
padding: 0px 8px;
flex: 1;
}
.color-input:hover {
border-color: var(--action-primary);
}
.color-value {
font-family: "Courier New", monospace;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
padding: 4px 8px;
background: var(--surface-main);
border-radius: 4px;
}
</style>
-103
View File
@@ -1,103 +0,0 @@
<script lang="ts">
type IconButtonVariant = "primary" | "secondary" | "danger";
type IconButtonSize = "sm" | "md" | "lg";
interface Props {
variant?: IconButtonVariant;
size?: IconButtonSize;
disabled?: boolean;
ariaLabel?: string;
class?: string;
children?: any;
onclick?: (event: MouseEvent) => void;
[key: string]: any;
}
let {
variant = "secondary",
size = "md",
disabled = false,
ariaLabel = "",
class: className = "",
children,
...restProps
}: Props = $props();
</script>
<button
{disabled}
aria-label={ariaLabel}
class:icon-btn-primary={variant === "primary"}
class:icon-btn-secondary={variant === "secondary"}
class:icon-btn-danger={variant === "danger"}
class:icon-btn-sm={size === "sm"}
class:icon-btn-md={size === "md"}
class:icon-btn-lg={size === "lg"}
class={className}
{...restProps}
>
{@render children?.()}
</button>
<style>
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
transition: all 0.2s ease;
background: none;
padding: 0.25rem 0.5rem;
font-size: 1.2rem;
}
.icon-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Sizes */
.icon-btn-sm {
padding: 0.125rem 0.25rem;
font-size: 1rem;
}
.icon-btn-md {
padding: 0.25rem 0.5rem;
font-size: 1.2rem;
}
.icon-btn-lg {
padding: 0.375rem 0.75rem;
font-size: 1.5rem;
}
/* Variants */
.icon-btn-primary {
color: #007bff;
}
.icon-btn-primary:hover:not(:disabled) {
color: #0056b3;
transform: scale(1.1);
}
.icon-btn-secondary {
color: #666;
}
.icon-btn-secondary:hover:not(:disabled) {
color: #007bff;
transform: scale(1.1);
}
.icon-btn-danger {
color: #dc3545;
}
.icon-btn-danger:hover:not(:disabled) {
color: #c82333;
transform: scale(1.1);
}
</style>
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import TypeOutline from "~icons/lucide/type-outline";
import Button from "./Button.svelte";
interface Props {
outlined: boolean;
}
let { outlined = $bindable(false) }: Props = $props();
</script>
<Button
icon={TypeOutline}
ariaLabel="toggle outline"
onclick={() => (outlined = !outlined)}
type={outlined ? "outline" : "primary"}
/>
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
import type { ChangeEventHandler } from "svelte/elements";
import Reset from "~icons/lucide/rotate-ccw";
import Button from "./Button.svelte";
interface Props {
value: number;
min?: number;
max?: number;
step?: number;
defaultValue?: number;
showReset?: boolean;
onchange?: ChangeEventHandler<HTMLInputElement>;
}
let {
value = $bindable(),
min = 0,
max = 100,
step = 1,
onchange = () => {},
defaultValue = 0,
showReset = false,
}: Props = $props();
</script>
<input type="range" {min} {max} {step} bind:value class="slider" {onchange} />
<span class="value-display">{value}</span>
{#if showReset}
<Button ariaLabel="Reset" icon={Reset} type="mini" onclick={() => (value = defaultValue)} />
{/if}
<style>
.slider {
flex: 1;
height: 4px;
border-radius: 2px;
background: var(--surface-control);
outline: none;
-webkit-appearance: none;
appearance: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--action-primary);
cursor: pointer;
transition: var(--transition);
}
.slider::-webkit-slider-thumb:hover {
transform: scale(1.1);
}
.slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--action-primary);
cursor: pointer;
border: none;
transition: var(--transition);
}
.slider::-moz-range-thumb:hover {
transform: scale(1.1);
}
.value-display {
color: var(--text-main);
font-weight: 500;
min-width: 40px;
text-align: right;
font-size: 13px;
padding: 4px 8px;
background: var(--surface-main);
border-radius: 4px;
}
</style>
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
interface Props {
value: string;
}
let { value = $bindable() }: Props = $props();
</script>
<select class="select-input" bind:value>
<option value="Arial">Arial</option>
<option value="Verdana">Verdana</option>
<option value="Georgia">Georgia</option>
<option value="Times New Roman">Times New Roman</option>
<option value="Courier New">Courier New</option>
<option value="Impact">Impact</option>
<option value="Comic Sans MS">Comic Sans MS</option>
<option value="Trebuchet MS">Trebuchet MS</option>
</select>
<style>
.select-input {
padding: 8px 10px;
border: 1px solid var(--border-main);
border-radius: var(--radius);
background: var(--surface-main);
color: var(--text-main);
font-size: 13px;
cursor: pointer;
transition: var(--transition);
font-family: inherit;
width: 100%;
}
.select-input:focus {
outline: none;
border-color: var(--border-strong);
}
</style>
+102 -85
View File
@@ -1,85 +1,102 @@
// Application Constants
// This file contains magic numbers and strings used in JavaScript/TypeScript logic
// ===== PANEL SETTINGS =====
export const PANEL_SETTINGS = {
// Storage
STORAGE_KEY: "twitch-panels",
MAX_PANELS_COUNT: 50,
// Dimensions
PANEL_WIDTH: 320,
PANEL_HEIGHT_DEFAULT: 100,
PANEL_HEIGHT_MAX: 1000,
// Default values
DEFAULT_BACKGROUND_IMAGE: "./backgrounds/b1.jpg",
} as const;
// ===== TYPOGRAPHY =====
export const TYPOGRAPHY = {
// Font families
FONT_FAMILY_DEFAULT: "Arial",
FONT_FAMILIES: [
"Arial",
"Verdana",
"Georgia",
"Times New Roman",
"Courier New",
"Impact",
"Comic Sans MS",
"Trebuchet MS",
],
// Font sizes for range inputs
FONT_SIZE_MIN: 10,
FONT_SIZE_MAX: 72,
FONT_SIZE_DEFAULT: 18,
// Text alignment
TEXT_ALIGN_LEFT: "left",
TEXT_ALIGN_CENTER: "center",
TEXT_ALIGN_RIGHT: "right",
// Text limits
MAX_TEXT_LENGTH: 100,
// Padding for range inputs
PADDING_X_DEFAULT: 10,
PADDING_X_LARGE: 20,
PADDING_X_MAX: 50,
// Vertical offset for range inputs
VERTICAL_OFFSET_MAX: 50,
VERTICAL_OFFSET_MIN: -50,
// Colors
TEXT_COLOR_DEFAULT: "#ffffff",
TEXT_COLOR_ERROR: "#c62828",
} as const;
// ===== IMAGE SETTINGS =====
export const IMAGE_SETTINGS = {
// File sizes
MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB
// Supported formats
SUPPORTED_FORMATS: ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"] as const,
} as const;
// ===== UI SETTINGS =====
export const UI_SETTINGS = {
// Steps
STEPS: {
UPLOAD: "upload",
CROP: "crop",
TEXT: "text",
} as const,
} as const;
// ===== ERROR HANDLING =====
export const ERROR_HANDLING = {
// Retry settings
MAX_RETRIES: 3,
RETRY_DELAY_MS: 1000,
} as const;
// Application Constants
// ===== PANEL SETTINGS =====
export const PANEL_SETTINGS = {
PANEL_WIDTH: 320,
PANEL_HEIGHT_DEFAULT: 100,
PANEL_HEIGHT_MAX: 200,
} as const;
// ===== TYPOGRAPHY =====
export const TYPOGRAPHY = {
FONT_FAMILY_DEFAULT: "Arial",
FONT_FAMILIES: [
"Arial",
"Verdana",
"Georgia",
"Times New Roman",
"Courier New",
"Impact",
"Comic Sans MS",
"Trebuchet MS",
],
// Font sizes for range inputs
FONT_SIZE_MIN: 10,
FONT_SIZE_MAX: 72,
FONT_SIZE_DEFAULT: 32,
// Text limits
MAX_TEXT_LENGTH: 100,
// Padding for range inputs
PADDING_X_DEFAULT: 10,
PADDING_X_MIN: 0,
PADDING_X_MAX: 100,
// Vertical offset for range inputs
OFFSET_Y_MAX: 100,
OFFSET_Y_MIN: -100,
OFFSET_Y_DEFAULT: 0,
// Colors
TEXT_COLOR_DEFAULT: "#ffffff",
} as const;
// ===== IMAGE SETTINGS =====
export const IMAGE_SETTINGS = {
MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB
SUPPORTED_FORMATS: ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"] as const,
DEFAULT_BACKGROUND_IMAGE: "./backgrounds/b1.jpg",
HUE_DEFAULT: 0,
HUE_MIN: 0,
HUE_MAX: 360,
SATURATION_DEFAULT: 0,
SATURATION_MIN: -20,
SATURATION_MAX: 50,
SATURATION_DIVIDER: 10,
LUMINANCE_DEFAULT: 0,
LUMINANCE_MIN: -100,
LUMINANCE_MAX: 100,
LUMINANCE_DIVIDER: 100,
BRIGHTNESS_DEFAULT: 100,
BRIGHTNESS_MIN: 0,
BRIGHTNESS_MAX: 200,
BRIGHTNESS_DIVIDER: 100,
CONTRAST_DEFAULT: 0,
CONTRAST_MIN: -100,
CONTRAST_MAX: 100,
} as const;
export const SlideDirection = {
NEXT: "next",
PREV: "prev",
} as const;
export type SlideDirectionType = (typeof SlideDirection)[keyof typeof SlideDirection];
export const TextAlign = {
LEFT: "left",
CENTER: "center",
RIGHT: "right",
} as const;
export type TextAlignType = (typeof TextAlign)[keyof typeof TextAlign];
export const TEXT_ALIGN_DEFAULT: TextAlignType = TextAlign.CENTER;
export const Theme = {
DARK: "dark",
LIGHT: "light",
} as const;
export type ThemeType = (typeof Theme)[keyof typeof Theme];
export const TRANSITION_DURATION = import.meta.env.MODE === "test" ? 0 : 300;
+37
View File
@@ -0,0 +1,37 @@
export class AppError extends Error {
constructor(
message: string,
public code: string,
public details?: unknown,
) {
super(message);
this.name = "AppError";
if (details) this.details = details;
}
}
export class ImageError extends AppError {
constructor(message: string) {
super(message, "IMAGE_ERROR");
}
}
export class TextError extends AppError {
constructor(message: string) {
super(message, "TEXT_ERROR");
}
}
export class CanvasError extends AppError {
constructor(message: string) {
super(message, "CANVAS_ERROR");
}
}
export class StorageError extends AppError {
constructor(message: string) {
super(message, "STORAGE_ERROR");
}
}
export type ErrorType = AppError | ImageError | TextError | CanvasError | StorageError;
-30
View File
@@ -1,30 +0,0 @@
import { building } from "$app/environment";
import { readdirSync } from "node:fs";
import { join } from "path";
export function loadFonts() {
try {
const fontsDir = join(process.cwd(), "static", "fonts");
const files = readdirSync(fontsDir);
let fonts = files
.filter((file) => /\.(woff2|woff|ttf|otf)$/i.test(file))
.map((file) => ({
name: file.replace(/\.[^/.]+$/, ""),
file,
url: `/fonts/${file}`,
format: getFormat(file),
}));
return fonts;
} catch (error) {
return [];
}
}
function getFormat(filename: string) {
if (filename.endsWith(".woff2")) return "woff2";
if (filename.endsWith(".woff")) return "woff";
if (filename.endsWith(".ttf")) return "truetype";
return "opentype";
}
-16
View File
@@ -1,16 +0,0 @@
import { building } from "$app/environment";
import { readdirSync } from "node:fs";
import { join } from "path";
export function loadImages() {
try {
const imagesDir = join(process.cwd(), "static", "backgrounds");
const files = readdirSync(imagesDir);
let fonts = files.filter((file) => /\.(png|jpg)$/i.test(file));
return fonts;
} catch (error) {
return [];
}
}
+1
View File
@@ -0,0 +1 @@
export type HexColor = `#${string}`;
-37
View File
@@ -1,37 +0,0 @@
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;
-48
View File
@@ -1,48 +0,0 @@
export type TextAlign = "left" | "center" | "right";
export interface TextItem {
id: string;
text: string;
fontSize: number;
fontFamily: string;
color: string;
textAlign: TextAlign;
paddingX: number;
verticalOffset: number;
}
export interface Panel {
id: string;
backgroundImage: string;
text: 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 | undefined;
currentStep: "upload" | "crop" | "text" | "preview";
showCropModal: boolean;
showTextManager: boolean;
}
-63
View File
@@ -1,63 +0,0 @@
import { ERROR_HANDLING } from "../constants";
import { AppError } 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 = ERROR_HANDLING.MAX_RETRIES,
delayMs: number = ERROR_HANDLING.RETRY_DELAY_MS,
): 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();
});
}
+26
View File
@@ -0,0 +1,26 @@
import { AppError } from "$lib/error.types";
export function formatError(error: unknown, defaultMessage: string = "Произошла ошибка"): string {
if (error instanceof AppError || error instanceof Error) {
return `${defaultMessage}: ${error.message}`;
}
if (typeof error === "string") {
return `${defaultMessage}: ${error}`;
}
return `${defaultMessage}: Произошла неизвестная ошибка`;
}
export function createError(message: string, code: string, details?: unknown): AppError {
return new AppError(message, code, 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,
});
}
-102
View File
@@ -1,102 +0,0 @@
import { IMAGE_SETTINGS } from "../constants";
import { ImageError } from "../types/errors";
export const MAX_FILE_SIZE = IMAGE_SETTINGS.MAX_FILE_SIZE;
export const SUPPORTED_FORMATS = IMAGE_SETTINGS.SUPPORTED_FORMATS;
export type ValidationResult =
| {
isValid: true;
}
| {
isValid: false;
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 as any)) {
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 = document.createElement("img");
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,
};
}
-137
View File
@@ -1,137 +0,0 @@
import { PANEL_SETTINGS } from "../constants";
import type { Panel } from "../types/panel";
import { handleError, logError } from "./errorHandler";
const STORAGE_KEY = PANEL_SETTINGS.STORAGE_KEY;
const MAX_PANELS = PANEL_SETTINGS.MAX_PANELS_COUNT;
export interface StorageResult {
success: boolean;
error?: string;
}
export class PanelStorage {
savePanel(panel: Panel): StorageResult {
try {
const panels = this.getAllPanels();
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, "Ошибка сохранения панели"),
};
}
}
getAllPanels(): Panel[] {
try {
const data = localStorage.getItem(STORAGE_KEY);
if (!data) {
return [];
}
const panels: Panel[] = JSON.parse(data);
// Convert date strings back to Date objects and validate
return panels
.map((panel) => ({
...panel,
createdAt: new Date(panel.createdAt),
updatedAt: new Date(panel.updatedAt),
}))
.filter((panel) => this.validatePanel(panel));
} catch (error) {
logError(error, "Failed to load panels");
return [];
}
}
getPanelById(id: string): Panel | undefined {
try {
const panels = this.getAllPanels();
return panels.find((p) => p.id === id) || undefined;
} catch (error) {
logError(error, "Failed to get panel by id");
return undefined;
}
}
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" &&
typeof panel.text === "object" &&
typeof panel.text?.id === "string" &&
typeof panel.text?.text === "string" &&
typeof panel.height === "number" &&
panel.height > 0 &&
panel.height <= PANEL_SETTINGS.PANEL_HEIGHT_MAX &&
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();
+41
View File
@@ -0,0 +1,41 @@
<script lang="ts">
import AppHeader from "$components/layout/AppHeader.svelte";
import { IMAGE_SETTINGS } from "$lib/constants";
import { uploadService } from "$services/uploadService";
import { imageState } from "$states/image.svelte";
import { themeState } from "$states/theme.svelte";
import { onMount, type Snippet } from "svelte";
import "../app.css";
interface Props {
children: Snippet;
}
$effect(() => {
const newTheme = themeState.current;
document.documentElement.setAttribute("data-theme", newTheme);
});
let { children }: Props = $props();
onMount(async () => {
let defaultImage = await uploadService.fromUrl(IMAGE_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
if (defaultImage.ok) {
imageState.fullImage = defaultImage.data;
} else {
console.error("Failed to load default image");
}
});
</script>
<div class="container">
<AppHeader />
{@render children()}
</div>
<style>
.container {
max-width: 1400px;
margin: 0 auto;
}
</style>
+2 -2
View File
@@ -1,2 +1,2 @@
export const ssr = false;
export const prerender = true;
export const ssr = false;
export const prerender = true;
+23 -121
View File
@@ -1,121 +1,23 @@
<script lang="ts">
import type { Panel, TextItem } from "$lib/types/panel";
import type { Stage as KonvaStage } from "konva/lib/Stage";
import { onMount } from "svelte";
import { exportService } from "../services/exportService";
import { imageService } from "../services/imageService";
import { panelService } from "../services/panelService";
import { textSettingsStore } from "../stores/panelStore";
import AppContainer from "../components/layout/AppContainer.svelte";
let uploadedImage = $state<string | undefined>(undefined);
let panels = $state<Panel[]>([]);
let texts = $state<Array<{ id: string; text: string }>>([]);
let backgroundImage = $state<string | undefined>(undefined);
let textSettings = $derived($textSettingsStore);
let previousSettings = $state<string>("");
$effect(() => {
const currentSettings = textSettings;
const settingsString = JSON.stringify(currentSettings);
if (settingsString !== previousSettings) {
const updatedPanels = panels.map((panel) => ({
...panel,
text: { ...panel.text, ...currentSettings },
}));
Promise.resolve().then(() => {
if (panels.length > 0) {
panels = updatedPanels;
}
});
previousSettings = settingsString;
}
});
onMount(async () => {
const defaultTexts = [
{ id: crypto.randomUUID(), text: "About" },
{ id: crypto.randomUUID(), text: "Links" },
];
texts = defaultTexts;
try {
const loadedBackground = await imageService.loadDefaultBackground();
backgroundImage = loadedBackground;
} catch (error) {
console.error("Failed to load default background");
exportService.setErrorMessage("Не удалось загрузить фоновое изображение по умолчанию");
}
const initialPanels = panelService.updatePanelsFromTexts(texts, [], backgroundImage || "");
panels = initialPanels;
});
function handleImageUpload(image: string) {
uploadedImage = image;
imageService.handleImageUpload(image);
}
function handleUploadNewImage() {
imageService.handleUploadNewImage();
}
function handleCropComplete(croppedImage: string) {
backgroundImage = croppedImage;
uploadedImage = undefined;
imageService.handleCropComplete(croppedImage);
panels = panelService.updatePanelsBackground(panels, croppedImage);
panels = panelService.updatePanelsFromTexts(texts, panels, croppedImage);
}
function handleCropCancel() {
uploadedImage = undefined;
imageService.handleCropCancel();
}
function handleAddText(text: string, settings?: Partial<TextItem>) {
texts = panelService.addText(texts, text);
panels = panelService.updatePanelsFromTexts(texts, panels, backgroundImage!, settings);
}
function handleUpdateText(id: string, newText: string) {
texts = panelService.updateText(texts, id, newText);
panels = panelService.updatePanelsFromTexts(texts, panels, backgroundImage!);
}
function handleDeleteText(id: string) {
texts = panelService.deleteText(texts, id);
panels = panelService.updatePanelsFromTexts(texts, panels, backgroundImage!);
}
async function handleDownload(panel: Panel, konvaStage: KonvaStage) {
await exportService.handleDownload(panel, konvaStage);
}
async function handleDownloadAll() {
await exportService.handleDownloadAll(panels);
}
</script>
<AppContainer
errorMessage={exportService.getErrorMessage() || undefined}
{uploadedImage}
{texts}
{backgroundImage}
{panels}
onUploadNewImage={handleUploadNewImage}
onImageUpload={handleImageUpload}
onCropComplete={handleCropComplete}
onCropCancel={handleCropCancel}
onTextAdd={handleAddText}
onTextUpdate={handleUpdateText}
onTextDelete={handleDeleteText}
onDownload={handleDownload}
onDownloadAll={handleDownloadAll}
/>
<script lang="ts">
import PanelBar from "$components/layout/PanelBar.svelte";
import TextBar from "$components/layout/TextBar.svelte";
</script>
<div class="main-grid">
<TextBar />
<PanelBar />
</div>
<style>
.main-grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
@media (min-width: 1024px) {
.main-grid {
grid-template-columns: 1fr 1fr;
}
}
</style>
+83
View File
@@ -0,0 +1,83 @@
import { ImageError } from "$lib/error.types";
import { formatError, logError } from "$lib/utils/errorUtils";
import { saveAs } from "file-saver";
import JSZip from "jszip";
import { Stage } from "konva/lib/Stage";
export type DownloadResult =
| {
success: true;
}
| {
success: false;
error: string;
};
export interface DownloadItem {
filename: string;
stage: Stage;
}
export class DownloadService {
async downloadPanel(konvaStage: Stage, label: string): Promise<DownloadResult> {
try {
const blob = await this.stageToBlob(konvaStage);
const filename = `${label}.png`;
saveAs(blob, filename);
return {
success: true,
};
} catch (error) {
logError(error, "Ошибка сохранения панели");
return {
success: false,
error: formatError(error, "Ошибка сохранения панели"),
};
}
}
async downloadAll(panels: Array<DownloadItem>): Promise<DownloadResult> {
try {
const zip = new JSZip();
for (const panel of panels) {
const blob = await this.stageToBlob(panel.stage);
zip.file(`${panel.filename}.png`, blob);
}
const zipBlob = await zip.generateAsync({ type: "blob" });
saveAs(zipBlob, "panels.zip");
return {
success: true,
};
} catch (error) {
logError(error, "Ошибка сохранения архива");
return {
success: false,
error: formatError(error, "Ошибка сохранения архива"),
};
}
}
private async stageToBlob(konvaStage: Stage): Promise<Blob> {
if (!konvaStage || typeof konvaStage.toBlob !== "function") {
throw new ImageError("Konva Stage не найден или не поддерживает toBlob");
}
return new Promise((resolve, reject) => {
konvaStage.toBlob({
callback: (blob: Blob | null) => {
if (blob) {
resolve(blob);
} else {
reject(new ImageError("Не удалось создать изображение из Konva Stage"));
}
},
});
});
}
}
export const downloadService = new DownloadService();
-98
View File
@@ -1,98 +0,0 @@
import pkg from "file-saver";
import type { Stage } from "konva/lib/Stage";
import { ImageError } from "../lib/types/errors";
import type { Panel } from "../lib/types/panel";
import { handleError, logError } from "../lib/utils/errorHandler";
const { saveAs } = pkg;
export interface ExportResult {
success: boolean;
error?: string;
}
export class ExportService {
async exportPanel(panel: Panel, konvaStage: Stage, filename?: string): Promise<ExportResult> {
try {
if (!konvaStage) {
throw new ImageError("Konva Stage не передан для экспорта");
}
const blob = await this.exportKonvaStageToBlob(konvaStage);
if (!blob) {
throw new ImageError("Не удалось создать изображение");
}
const defaultFilename = `twitch-panel-${panel.id}.png`;
saveAs(blob, filename || defaultFilename);
return {
success: true,
};
} catch (error) {
logError(error, "Panel export failed");
return {
success: false,
error: handleError(error, "Ошибка экспорта панели"),
};
}
}
private async exportKonvaStageToBlob(konvaStage: Stage): Promise<Blob | null> {
if (!konvaStage || typeof konvaStage.toBlob !== "function") {
throw new ImageError("Konva Stage не найден или не поддерживает toBlob");
}
return new Promise((resolve, reject) => {
konvaStage.toBlob({
callback: (blob: Blob | null) => {
if (blob) {
resolve(blob);
} else {
reject(new ImageError("Не удалось создать изображение из Konva Stage"));
}
},
});
});
}
/**
* Экспортирует несколько панелей в ZIP архив
*/
async exportPanels(panels: Panel[]): Promise<ExportResult> {
return {
success: false,
error: "Пакетный экспорт еще не реализован",
};
}
/**
* Обработчик для скачивания одной панели
*/
async handleDownload(panel: Panel, konvaStage: Stage): Promise<void> {
const result = await this.exportPanel(panel, konvaStage);
if (!result.success) {
throw new Error(result.error || "Ошибка экспорта панели");
}
}
/**
* Обработчик для скачивания всех панелей
*/
async handleDownloadAll(panels: Panel[]): Promise<void> {
const result = await this.exportPanels(panels);
if (!result.success) {
throw new Error(result.error || "Ошибка экспорта панелей");
}
}
getErrorMessage(): string | null {
return null;
}
setErrorMessage(message: string): void {
console.error("ExportService error:", message);
}
}
export const exportService = new ExportService();
-130
View File
@@ -1,130 +0,0 @@
import { PANEL_SETTINGS } from "../lib/constants";
import { setCurrentStep } from "../stores/uiStore";
export class ImageService {
private static instance: ImageService;
private constructor() {}
static getInstance(): ImageService {
if (!ImageService.instance) {
ImageService.instance = new ImageService();
}
return ImageService.instance;
}
async loadDefaultBackground(): Promise<string> {
const defaultBackground = PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE;
const response = await fetch(defaultBackground);
if (!response.ok) throw new Error("Не удалось загрузить фоновое изображение");
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
handleImageUpload(image: string): void {
setCurrentStep("crop");
}
handleUploadNewImage(): void {
setCurrentStep("upload");
}
handleCropComplete(croppedImage: string): void {
setCurrentStep("text");
}
handleCropCancel(): void {
setCurrentStep("text");
}
async handleFileUpload(file: File): Promise<any> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () =>
resolve({
success: true,
image: reader.result as string,
});
reader.onerror = () =>
reject({
success: false,
error: "Ошибка чтения файла",
});
reader.readAsDataURL(file);
});
}
async handlePasteUpload(pasteEvent: ClipboardEvent): Promise<any> {
const items = pasteEvent.clipboardData?.items;
if (!items || items.length === 0) {
return {
success: false,
error: "В буфере обмена не найдено изображений",
};
}
let imageItem: DataTransferItem | undefined = undefined;
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: "В буфере обмена не найдено изображений",
};
}
const file = imageItem.getAsFile();
if (!file) {
return {
success: false,
error: "Не удалось получить файл из буфера обмена",
};
}
return this.handleFileUpload(file);
}
async handleUrlUpload(url: string): Promise<any> {
try {
const response = await fetch(url);
if (!response.ok) {
return {
success: false,
error: "Не удалось загрузить изображение по URL",
};
}
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () =>
resolve({
success: true,
image: reader.result as string,
});
reader.onerror = () =>
reject({
success: false,
error: "Ошибка чтения изображения",
});
reader.readAsDataURL(blob);
});
} catch (error) {
return {
success: false,
error: "Ошибка загрузки изображения по URL",
};
}
}
}
export const imageService = ImageService.getInstance();
-71
View File
@@ -1,71 +0,0 @@
import { PANEL_SETTINGS } from "../lib/constants";
import type { Panel, TextItem } from "../lib/types/panel";
import { createPanelFromText, updatePanelText } from "../stores/panelStore";
export class PanelService {
private static instance: PanelService;
private constructor() {}
static getInstance(): PanelService {
if (!PanelService.instance) {
PanelService.instance = new PanelService();
}
return PanelService.instance;
}
validateText(text: string): boolean {
return text.trim().length > 0;
}
isDuplicateText(texts: Array<{ id: string; text: string }>, text: string, excludeId?: string): boolean {
return texts.some((t) => t.id !== excludeId && t.text === text);
}
addText(texts: Array<{ id: string; text: string }>, text: string): Array<{ id: string; text: string }> {
if (!this.validateText(text)) return texts;
if (this.isDuplicateText(texts, text)) return texts;
return [...texts, { id: crypto.randomUUID(), text }];
}
updateText(
texts: Array<{ id: string; text: string }>,
id: string,
newText: string,
): Array<{ id: string; text: string }> {
if (!this.validateText(newText)) return texts;
if (this.isDuplicateText(texts, newText, id)) return texts;
return texts.map((t) => (t.id === id ? { ...t, text: newText } : t));
}
deleteText(texts: Array<{ id: string; text: string }>, id: string): Array<{ id: string; text: string }> {
return texts.filter((t) => t.id !== id);
}
updatePanelsFromTexts(
texts: Array<{ id: string; text: string }>,
panels: Panel[],
backgroundImage: string,
textSettings?: Partial<TextItem>,
): Panel[] {
return texts.map((textItem) => {
const existingPanel = panels.find((p) => p.text.text === textItem.text);
if (existingPanel) {
return updatePanelText(existingPanel, textItem.text);
}
return createPanelFromText(backgroundImage, textItem.text, PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT, textSettings);
});
}
updatePanelsBackground(panels: Panel[], newBackground: string): Panel[] {
return panels.map((panel) => ({
...panel,
backgroundImage: newBackground,
updatedAt: new Date(),
}));
}
}
export const panelService = PanelService.getInstance();
+51
View File
@@ -0,0 +1,51 @@
export type UploadResult =
| { ok: true; data: string; error?: never }
| { ok: false; error: string; data?: never };
export const uploadService = {
async _process(file: File): Promise<UploadResult> {
if (!file.type.startsWith("image/")) {
return { ok: false, error: "Not an image" };
}
try {
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result as string);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
const img = new Image();
img.src = base64;
await img.decode();
return { ok: true, data: base64 };
} catch {
return { ok: false, error: "File damage or unsupported" };
}
},
async fromFile(file: File | undefined): Promise<UploadResult> {
if (!file) return { ok: false, error: "No file" };
return this._process(file);
},
async fromClipboard(event: ClipboardEvent): Promise<UploadResult> {
const file = event.clipboardData?.files[0];
return this.fromFile(file);
},
async fromUrl(url: string): Promise<UploadResult> {
if (!url) return { ok: false, error: "No url" };
try {
const response = await fetch(url);
if (!response.ok) return { ok: false, error: "Invalid url" };
const blob = await response.blob();
const file = new File([blob], "image", { type: blob.type });
return this._process(file);
} catch {
return { ok: false, error: "Invalid url, or CORS error" };
}
},
};
+148
View File
@@ -0,0 +1,148 @@
import { IMAGE_SETTINGS } from "$lib/constants";
import { withPersistence, type Persistable } from "./persisted.svelte";
// export const DEBOUNCE_DURATION = import.meta.env.MODE === "test" ? 0 : 200;
export const DEBOUNCE_DURATION = 150;
export const INITIAL_DELAY_DURATION = 500;
export interface Rect {
left: number;
top: number;
right: number;
bottom: number;
}
export interface ImageConfigDTO {
fullImage: string | null;
cropRect: Rect;
brightness: number;
contrast: number;
hue: number;
saturation: number;
luminance: number;
}
const ImageConfigDTOKeys: (keyof ImageConfigDTO)[] = [
"fullImage",
"cropRect",
"brightness",
"contrast",
"hue",
"saturation",
"luminance",
];
export interface ImageConfig {
fullImage: string | null;
croppedImage: HTMLImageElement | undefined;
cropRect: Rect;
brightness: number;
contrast: number;
hue: number;
saturation: number;
luminance: number;
isReady: boolean;
appliedFilters: FilterConfig;
}
interface FilterConfig {
brightness: number;
contrast: number;
hue: number;
saturation: number;
luminance: number;
}
export class ImageState implements Persistable<ImageConfigDTO> {
#fullImage: string = $state("");
croppedImage: HTMLImageElement | undefined = $state(undefined);
masterImage: HTMLCanvasElement | undefined = $state(undefined);
cropRect: Rect = $state({ left: 0, top: 0, right: 0, bottom: 0 });
brightness: number = $state(IMAGE_SETTINGS.BRIGHTNESS_DEFAULT);
contrast: number = $state(IMAGE_SETTINGS.CONTRAST_DEFAULT);
hue: number = $state(IMAGE_SETTINGS.HUE_DEFAULT);
saturation: number = $state(IMAGE_SETTINGS.SATURATION_DEFAULT);
luminance: number = $state(IMAGE_SETTINGS.LUMINANCE_DEFAULT);
isReady: boolean = $state(false);
appliedFilters: FilterConfig = $state({
brightness: 0,
contrast: 0,
hue: 0,
saturation: 0,
luminance: 0,
});
constructor() {
$effect.root(() => {
$effect(() => {
const brightness = this.brightness;
const contrast = this.contrast;
const hue = this.hue;
const saturation = this.saturation;
const luminance = this.luminance;
this.isReady = false;
const timeout = setTimeout(() => {
this.updateFilters({
hue,
saturation,
luminance,
brightness,
contrast,
});
}, DEBOUNCE_DURATION);
return () => clearTimeout(timeout);
});
});
}
updateFilters(newFilters: FilterConfig) {
this.appliedFilters = newFilters;
this.isReady = true;
}
reset() {
this.#fullImage = "";
this.croppedImage = undefined;
this.cropRect = { left: 0, top: 0, right: 0, bottom: 0 };
this.brightness = 0;
this.contrast = 0;
this.hue = 0;
}
get fullImage() {
return this.#fullImage;
}
set fullImage(image: string) {
if (image) {
const img = new Image();
img.onload = () => {
this.#fullImage = image;
this.croppedImage = img;
};
img.src = image;
}
}
toSnapshot(): ImageConfigDTO {
return {
fullImage: this.#fullImage,
cropRect: this.cropRect,
brightness: this.brightness,
contrast: this.contrast,
hue: this.hue,
saturation: this.saturation,
luminance: this.luminance,
};
}
fromSnapshot(data: Partial<ImageConfigDTO>): void {
for (const key of ImageConfigDTOKeys) {
if (key in data && data[key] !== undefined) {
(this as ImageConfigDTO)[key] = data[key] as never;
}
}
}
}
export const imageState = withPersistence("image", new ImageState());
+3
View File
@@ -0,0 +1,3 @@
import type { Stage } from "svelte-konva";
export const konvaAllStagesState: Array<Stage> = $state([]);
+7
View File
@@ -0,0 +1,7 @@
import type { Stage } from "svelte-konva";
export class KonvaStage {
stage: Stage | undefined = $state(undefined);
}
export const konvaStageState = new KonvaStage();
+45
View File
@@ -0,0 +1,45 @@
import { browser } from "$app/environment";
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
export const DEBOUNCE_DURATION = import.meta.env.MODE === "test" ? 0 : 500;
export interface Persistable<D> {
toSnapshot(): D;
fromSnapshot(data: Partial<D>): void;
}
export function withPersistence<D, T extends Persistable<D>>(
key: string,
state: T,
debounceMs = DEBOUNCE_DURATION,
): T {
if (!browser) return state;
const saved = localStorage.getItem(key);
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed) {
state.fromSnapshot(parsed);
}
} catch (e) {
console.error(`Error repairing state for ${key}`, e);
}
}
$effect.root(() => {
$effect(() => {
const data = JSON.stringify($state.snapshot(state.toSnapshot()));
if (debounceMs > 0) {
const timeout = setTimeout(() => localStorage.setItem(key, data), debounceMs);
return () => clearTimeout(timeout);
} else {
localStorage.setItem(key, data);
}
});
});
return state;
}
+55
View File
@@ -0,0 +1,55 @@
import { TEXT_ALIGN_DEFAULT, TYPOGRAPHY, type TextAlignType } from "$lib/constants";
import type { HexColor } from "$lib/types";
import { withPersistence, type Persistable } from "./persisted.svelte";
export type TextConfigDTO = {
fontSize: number;
fontFamily: string;
color: HexColor;
align: TextAlignType;
paddingX: number;
offsetY: number;
outlined: boolean;
};
const textConfigKeys: (keyof TextConfigDTO)[] = [
"fontSize",
"fontFamily",
"color",
"align",
"paddingX",
"offsetY",
"outlined",
];
export class TextConfigState implements Persistable<TextConfigDTO> {
fontSize: number = $state(TYPOGRAPHY.FONT_SIZE_DEFAULT);
fontFamily: string = $state(TYPOGRAPHY.FONT_FAMILY_DEFAULT);
color: HexColor = $state(TYPOGRAPHY.TEXT_COLOR_DEFAULT);
align: TextAlignType = $state(TEXT_ALIGN_DEFAULT);
paddingX: number = $state(TYPOGRAPHY.PADDING_X_DEFAULT);
offsetY: number = $state(TYPOGRAPHY.OFFSET_Y_DEFAULT);
outlined: boolean = $state(false);
toSnapshot(): TextConfigDTO {
return {
fontSize: this.fontSize,
fontFamily: this.fontFamily,
color: this.color,
align: this.align,
paddingX: this.paddingX,
offsetY: this.offsetY,
outlined: this.outlined,
};
}
fromSnapshot(data: Partial<TextConfigDTO>): void {
for (const key of textConfigKeys) {
if (key in data && data[key] !== undefined) {
(this as TextConfigDTO)[key] = data[key] as never;
}
}
}
}
export const textConfigState = withPersistence("text-config", new TextConfigState());
+50
View File
@@ -0,0 +1,50 @@
import { withPersistence, type Persistable } from "./persisted.svelte";
export interface TextItem {
text: string;
id: number;
}
export type TextState = Array<TextItem>;
export type TextStateDTO = Array<string>;
const defaultTexts: TextStateDTO = ["About me", "Links", "Projects"];
export class TextsState implements Persistable<TextStateDTO> {
#texts: Array<TextItem> = $state(this.fromDTO(defaultTexts));
#nextId = $state(defaultTexts.length);
get texts() {
return this.#texts;
}
addText(text: string) {
if (text.trim().length === 0) return;
this.#texts.push({ text, id: this.#nextId });
this.#nextId++;
}
removeText(id: number) {
this.#texts = this.#texts.filter((textItem) => textItem.id !== id);
}
clear() {
this.#texts = [];
this.#nextId = 0;
}
toSnapshot(): TextStateDTO {
return this.#texts.map(({ text }) => text);
}
fromSnapshot(data: TextStateDTO) {
this.#texts = this.fromDTO(data);
this.#nextId = data.length;
}
fromDTO(data: TextStateDTO): TextState {
return data.map((text, idx) => ({ text, id: idx }));
}
}
export const textsState = withPersistence("texts", new TextsState());
+28
View File
@@ -0,0 +1,28 @@
import { Theme, type ThemeType } from "$lib/constants";
import { withPersistence, type Persistable } from "./persisted.svelte";
export interface Theme {
current: ThemeType;
}
export class ThemeState implements Persistable<Theme> {
current: ThemeType = $state(Theme.LIGHT);
toggle() {
this.current = this.current === Theme.DARK ? Theme.LIGHT : Theme.DARK;
}
toSnapshot(): Theme {
return {
current: this.current,
};
}
fromSnapshot(data: Partial<Theme>): void {
if (data.current !== undefined) {
this.current = data.current;
}
}
}
export const themeState = withPersistence("theme", new ThemeState());
-88
View File
@@ -1,88 +0,0 @@
import { writable, type Writable } from "svelte/store";
import { v4 as uuidv4 } from "uuid";
import { PANEL_SETTINGS, TYPOGRAPHY } from "../lib/constants";
import { type Panel, type TextItem } from "../lib/types/panel";
export const panelStore: Writable<Panel | undefined> = writable(undefined);
export const textSettingsStore = writable<Partial<TextItem>>({
fontSize: TYPOGRAPHY.FONT_SIZE_DEFAULT,
fontFamily: TYPOGRAPHY.FONT_FAMILY_DEFAULT,
color: TYPOGRAPHY.TEXT_COLOR_DEFAULT,
textAlign: TYPOGRAPHY.TEXT_ALIGN_LEFT,
paddingX: TYPOGRAPHY.PADDING_X_DEFAULT,
verticalOffset: 0,
});
export const updateAllTextSettings = (settings: Partial<TextItem>) => {
textSettingsStore.update((current) => ({ ...current, ...settings }));
};
export const createEmptyPanel = (height: number = PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT): Panel => {
const defaultText: TextItem = {
id: uuidv4(),
text: "",
fontSize: TYPOGRAPHY.FONT_SIZE_DEFAULT,
fontFamily: TYPOGRAPHY.FONT_FAMILY_DEFAULT,
color: TYPOGRAPHY.TEXT_COLOR_DEFAULT,
textAlign: TYPOGRAPHY.TEXT_ALIGN_CENTER,
paddingX: TYPOGRAPHY.PADDING_X_DEFAULT,
verticalOffset: 0,
};
return {
id: uuidv4(),
backgroundImage: "",
text: defaultText,
height,
createdAt: new Date(),
updatedAt: new Date(),
};
};
export const updatePanel = (panel: Panel, updates: Partial<Panel>): Panel => {
return {
...panel,
...updates,
updatedAt: new Date(),
};
};
export const updatePanelText = (panel: Panel, text: string): Panel => {
return updatePanel(panel, {
text: { ...panel.text, text },
});
};
export const updateTextProperties = (panel: Panel, updates: Partial<TextItem>): Panel => {
return updatePanel(panel, {
text: { ...panel.text, ...updates },
});
};
export const createPanelFromText = (
backgroundImage: string,
text: string,
height: number = PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT,
textSettings?: Partial<TextItem>,
): Panel => {
const newText: TextItem = {
id: uuidv4(),
text,
fontSize: textSettings?.fontSize ?? TYPOGRAPHY.FONT_SIZE_DEFAULT,
fontFamily: textSettings?.fontFamily ?? TYPOGRAPHY.FONT_FAMILY_DEFAULT,
color: textSettings?.color ?? TYPOGRAPHY.TEXT_COLOR_DEFAULT,
textAlign: textSettings?.textAlign ?? TYPOGRAPHY.TEXT_ALIGN_CENTER,
paddingX: textSettings?.paddingX ?? TYPOGRAPHY.PADDING_X_DEFAULT,
verticalOffset: textSettings?.verticalOffset ?? 0,
};
return {
id: uuidv4(),
backgroundImage,
text: newText,
height,
createdAt: new Date(),
updatedAt: new Date(),
};
};
-44
View File
@@ -1,44 +0,0 @@
import { writable, type Writable } from "svelte/store";
import { type UIState } from "../lib/types/panel";
export const uiStore: Writable<UIState> = writable({
isLoading: false,
error: undefined,
currentStep: "text",
showCropModal: false,
showTextManager: false,
});
export const setLoading = (loading: boolean): void => {
uiStore.update((state) => ({ ...state, isLoading: loading }));
};
export const setError = (error: string | undefined): void => {
uiStore.update((state) => ({ ...state, error }));
};
export const clearError = (): void => {
setError(undefined);
};
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: undefined,
currentStep: "text",
showCropModal: false,
showTextManager: false,
});
};
+2
View File
@@ -14,7 +14,9 @@ const config = {
},
alias: {
$components: "src/components",
$routes: "src/routes",
$stores: "src/stores",
$states: "src/states",
$services: "src/services",
},
},
+3 -79
View File
@@ -1,79 +1,3 @@
import { vi } from "vitest";
// Extend global type
declare global {
var testUtils: {
createMockFile: (name?: string, size?: number, type?: string) => File;
createMockPanel: (overrides?: any) => any;
waitFor: (ms: number) => Promise<void>;
};
}
// Mock localStorage for testing
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
length: 0,
key: vi.fn(),
};
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});
// Mock crypto for testing
Object.defineProperty(window, "crypto", {
value: {
randomUUID: () => "test-uuid-12345",
},
});
// Mock FileReader for image upload testing
Object.defineProperty(window, "FileReader", {
value: vi.fn(() => ({
readAsDataURL: vi.fn(),
onload: null,
onerror: null,
result: "data:image/jpeg;base64,/9j/4AAQSkZJRg==",
})),
});
// Global test utilities
global.testUtils = {
createMockFile: (name = "test.jpg", size = 1024, type = "image/jpeg") => {
const blob = new Blob([new ArrayBuffer(size)], { type });
return new File([blob], name, { type });
},
createMockPanel: (overrides = {}) => ({
id: "test-panel-id",
backgroundImage: "/backgrounds/b1.jpg",
text: {
id: "test-text-id",
text: "Test Panel",
fontSize: 18,
fontFamily: "Arial",
color: "#ffffff",
textAlign: "center" as const,
paddingX: 10,
verticalOffset: 0,
},
height: 100,
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
...overrides,
}),
waitFor: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
};
// Export cleanup function for use in individual test files
export function cleanupMocks() {
vi.clearAllMocks();
localStorageMock.getItem.mockClear();
localStorageMock.setItem.mockClear();
localStorageMock.removeItem.mockClear();
localStorageMock.clear.mockClear();
}
import "@testing-library/jest-dom/vitest";
import "vitest-canvas-mock";
import "web-animations-js";
@@ -0,0 +1,79 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Application Constants Logic > Global Contract (Snapshot) > should match the previous configuration snapshot 1`] = `
{
"IMAGE_SETTINGS": {
"BRIGHTNESS_DEFAULT": 100,
"BRIGHTNESS_DIVIDER": 100,
"BRIGHTNESS_MAX": 200,
"BRIGHTNESS_MIN": 0,
"CONTRAST_DEFAULT": 0,
"CONTRAST_MAX": 100,
"CONTRAST_MIN": -100,
"DEFAULT_BACKGROUND_IMAGE": "./backgrounds/b1.jpg",
"HUE_DEFAULT": 0,
"HUE_MAX": 360,
"HUE_MIN": 0,
"LUMINANCE_DEFAULT": 0,
"LUMINANCE_DIVIDER": 100,
"LUMINANCE_MAX": 100,
"LUMINANCE_MIN": -100,
"MAX_FILE_SIZE": 10485760,
"SATURATION_DEFAULT": 0,
"SATURATION_DIVIDER": 10,
"SATURATION_MAX": 50,
"SATURATION_MIN": -20,
"SUPPORTED_FORMATS": [
"image/jpeg",
"image/jpg",
"image/png",
"image/webp",
"image/gif",
],
},
"PANEL_SETTINGS": {
"PANEL_HEIGHT_DEFAULT": 100,
"PANEL_HEIGHT_MAX": 200,
"PANEL_WIDTH": 320,
},
"SlideDirection": {
"NEXT": "next",
"PREV": "prev",
},
"TEXT_ALIGN_DEFAULT": "center",
"TRANSITION_DURATION": 0,
"TYPOGRAPHY": {
"FONT_FAMILIES": [
"Arial",
"Verdana",
"Georgia",
"Times New Roman",
"Courier New",
"Impact",
"Comic Sans MS",
"Trebuchet MS",
],
"FONT_FAMILY_DEFAULT": "Arial",
"FONT_SIZE_DEFAULT": 32,
"FONT_SIZE_MAX": 72,
"FONT_SIZE_MIN": 10,
"MAX_TEXT_LENGTH": 100,
"OFFSET_Y_DEFAULT": 0,
"OFFSET_Y_MAX": 100,
"OFFSET_Y_MIN": -100,
"PADDING_X_DEFAULT": 10,
"PADDING_X_MAX": 100,
"PADDING_X_MIN": 0,
"TEXT_COLOR_DEFAULT": "#ffffff",
},
"TextAlign": {
"CENTER": "center",
"LEFT": "left",
"RIGHT": "right",
},
"Theme": {
"DARK": "dark",
"LIGHT": "light",
},
}
`;
-297
View File
@@ -1,297 +0,0 @@
import ImageUpload from "$components/image/ImageUpload.svelte";
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock the stores and services
vi.mock("$stores/uiStore", () => ({
uiStore: {
subscribe: vi.fn((callback) => {
callback({ error: null, loading: false });
return () => {};
}),
},
setCurrentStep: vi.fn(),
setLoading: vi.fn(),
clearError: vi.fn(),
}));
vi.mock("$services/imageService", () => ({
imageService: {
validateAndProcessImage: vi.fn(),
},
}));
vi.mock("$lib/utils/errorHandler", () => ({
handleError: vi.fn((error) => `Error: ${error}`),
}));
describe("ImageUpload", () => {
let mockOnImageSelect: (image: string) => void;
let mockImageService: any;
beforeEach(() => {
mockOnImageSelect = vi.fn();
vi.clearAllMocks();
// Setup mock image service
mockImageService = {
validateAndProcessImage: vi.fn().mockResolvedValue({
success: true,
imageUrl: "data:image/jpeg;base64,/9j/4AAQSkZJRg==",
}),
};
});
it("should render upload component", () => {
const { container } = render(ImageUpload, {
props: {
onImageSelect: mockOnImageSelect,
},
});
expect(container).toBeTruthy();
});
it("should handle file selection via input", async () => {
const { container } = render(ImageUpload, {
props: {
onImageSelect: mockOnImageSelect,
},
});
// Create a mock file
const mockFile = new File(["test image content"], "test.jpg", {
type: "image/jpeg",
});
// Find file input
const fileInput = container.querySelector('input[type="file"]');
expect(fileInput).toBeTruthy();
if (fileInput) {
// Simulate file selection
await fireEvent.change(fileInput, {
target: { files: [mockFile] },
});
// Should call image service
await waitFor(() => {
expect(mockImageService.validateAndProcessImage).toHaveBeenCalled();
});
}
});
it("should handle drag and drop", async () => {
const { container } = render(ImageUpload, {
props: {
onImageSelect: mockOnImageSelect,
},
});
// Create mock file
const mockFile = new File(["test image content"], "test.jpg", {
type: "image/jpeg",
});
// Find drop zone
const dropZone = container.querySelector("[data-testid='drop-zone'], .drop-zone, .upload-area");
if (dropZone) {
// Simulate drag over
await fireEvent.dragOver(dropZone, {
dataTransfer: { files: [mockFile] },
});
// Simulate drop
await fireEvent.drop(dropZone, {
dataTransfer: { files: [mockFile] },
});
// Should process the image
await waitFor(() => {
expect(mockImageService.validateAndProcessImage).toHaveBeenCalled();
});
}
});
it("should handle URL input", async () => {
const { container } = render(ImageUpload, {
props: {
onImageSelect: mockOnImageSelect,
},
});
// Find URL input and toggle button
const urlInputs = container.querySelectorAll('input[type="url"], input[placeholder*="URL"]');
const buttons = screen.getAllByRole("button");
// Toggle URL input if needed
const toggleButton = buttons.find(
(button) =>
button.textContent?.includes("URL") ||
button.textContent?.includes("Ссылка") ||
button.textContent?.includes("By URL"),
);
if (toggleButton) {
await fireEvent.click(toggleButton);
}
if (urlInputs.length > 0) {
const testUrl = "https://example.com/image.jpg";
await fireEvent.input(urlInputs[0], { target: { value: testUrl } });
// Submit URL form
const submitButton = buttons.find((button) => {
const isLoadButton = button.textContent?.includes("Load") || button.textContent?.includes("Загрузить");
const buttonType = (button as HTMLButtonElement).type;
return isLoadButton || buttonType === "submit";
});
if (submitButton) {
await fireEvent.click(submitButton);
// Should process the URL
await waitFor(() => {
expect(mockImageService.validateAndProcessImage).toHaveBeenCalled();
});
}
}
});
it("should handle paste event", async () => {
render(ImageUpload, {
props: {
onImageSelect: mockOnImageSelect,
},
});
// Create mock clipboard data
const mockClipboardData = {
items: [
{
kind: "file",
type: "image/jpeg",
getAsFile: () => new File(["pasted image"], "pasted.jpg", { type: "image/jpeg" }),
},
],
};
// Simulate paste event
const pasteEvent = new ClipboardEvent("paste", {
clipboardData: mockClipboardData as any,
});
document.dispatchEvent(pasteEvent);
// Should process the pasted image
await waitFor(() => {
expect(mockImageService.validateAndProcessImage).toHaveBeenCalled();
});
});
it("should show error for invalid image", async () => {
// Mock image service to return error
mockImageService.validateAndProcessImage.mockResolvedValue({
success: false,
error: "Invalid image format",
});
const { container } = render(ImageUpload, {
props: {
onImageSelect: mockOnImageSelect,
},
});
const mockFile = new File(["invalid content"], "test.txt", {
type: "text/plain",
});
const fileInput = container.querySelector('input[type="file"]');
if (fileInput) {
await fireEvent.change(fileInput, {
target: { files: [mockFile] },
});
// Should show error message
await waitFor(() => {
expect(container.textContent).toContain("Error");
});
}
});
it("should call onImageSelect when image is successfully processed", async () => {
const mockImageUrl = "data:image/jpeg;base64,/9j/4AAQSkZJRg==";
// Mock successful image processing
mockImageService.validateAndProcessImage.mockResolvedValue({
success: true,
imageUrl: mockImageUrl,
});
const { container } = render(ImageUpload, {
props: {
onImageSelect: mockOnImageSelect,
},
});
const mockFile = new File(["test image content"], "test.jpg", {
type: "image/jpeg",
});
const fileInput = container.querySelector('input[type="file"]');
if (fileInput) {
await fireEvent.change(fileInput, {
target: { files: [mockFile] },
});
// Should call onImageSelect with processed image
await waitFor(() => {
expect(mockOnImageSelect).toHaveBeenCalledWith(mockImageUrl);
});
}
});
it("should show loading state during processing", async () => {
const { setLoading } = await import("$stores/uiStore");
// Mock slow processing
mockImageService.validateAndProcessImage.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(
() =>
resolve({
success: true,
imageUrl: "data:image/jpeg;base64,/9j/4AAQSkZJRg==",
}),
100,
),
),
);
const { container } = render(ImageUpload, {
props: {
onImageSelect: mockOnImageSelect,
},
});
const mockFile = new File(["test image content"], "test.jpg", {
type: "image/jpeg",
});
const fileInput = container.querySelector('input[type="file"]');
if (fileInput) {
await fireEvent.change(fileInput, {
target: { files: [mockFile] },
});
// Should show loading state
expect(setLoading).toHaveBeenCalledWith(true);
// Wait for processing to complete
await waitFor(() => {
expect(setLoading).toHaveBeenCalledWith(false);
});
}
});
});
@@ -1,99 +0,0 @@
import PanelPreview from "$components/panel/PanelPreview.svelte";
import type { Panel } from "$lib/types/panel";
import { fireEvent, render, screen } from "@testing-library/svelte";
import type { Stage as KonvaStage } from "konva/lib/Stage";
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock the UI store
vi.mock("$stores/uiStore", () => ({
setCurrentStep: vi.fn(),
}));
describe("PanelPreview", () => {
let mockPanel: Panel;
let mockOnDownload: (panel: Panel, konvaStage: KonvaStage) => void;
beforeEach(() => {
mockPanel = {
id: "test-panel-1",
backgroundImage: "/backgrounds/b1.jpg",
text: {
id: "test-text-1",
text: "Test Panel",
fontSize: 18,
fontFamily: "Arial",
color: "#ffffff",
textAlign: "center",
paddingX: 10,
verticalOffset: 0,
},
height: 100,
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
};
mockOnDownload = vi.fn();
});
it("should render panel with text content", () => {
const { container } = render(PanelPreview, {
props: {
panel: mockPanel,
onDownload: mockOnDownload,
},
});
// Should render without errors
expect(container).toBeTruthy();
});
it("should call onDownload when download button is clicked", async () => {
render(PanelPreview, {
props: {
panel: mockPanel,
onDownload: mockOnDownload,
},
});
// Find and click download button - button text might vary, so we'll look for a button
const buttons = screen.getAllByRole("button");
const downloadButton = buttons.find(
(button) => button.textContent?.includes("Скачать") || button.textContent?.includes("Download"),
);
if (downloadButton) {
await fireEvent.click(downloadButton);
// Should call onDownload with panel and konva stage
expect(mockOnDownload).toHaveBeenCalledWith(mockPanel, expect.any(Object));
}
});
it("should handle panel without onDownload callback", () => {
const { container } = render(PanelPreview, {
props: {
panel: mockPanel,
},
});
// Should render without errors
expect(container).toBeTruthy();
});
it("should display panel text content", () => {
render(PanelPreview, {
props: {
panel: mockPanel,
onDownload: mockOnDownload,
},
});
// Check if panel text is rendered (this might be in a canvas, so we check container)
const { container } = render(PanelPreview, {
props: {
panel: mockPanel,
onDownload: mockOnDownload,
},
});
expect(container).toBeTruthy();
});
});
-242
View File
@@ -1,242 +0,0 @@
import TextManager from "$components/text/TextManager.svelte";
import { fireEvent, render, screen } from "@testing-library/svelte";
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock the stores
vi.mock("$stores/panelStore", () => ({
textSettingsStore: {
subscribe: vi.fn((callback) => {
callback({
fontSize: 18,
fontFamily: "Arial",
color: "#ffffff",
textAlign: "center",
paddingX: 10,
verticalOffset: 0,
});
return () => {}; // unsubscribe function
}),
update: vi.fn(),
},
updateAllTextSettings: vi.fn(),
}));
describe("TextManager", () => {
let mockOnTextAdd: (text: string, settings?: any) => void;
let mockOnTextUpdate: (id: string, text: string) => void;
let mockOnTextDelete: (id: string) => void;
beforeEach(() => {
mockOnTextAdd = vi.fn();
mockOnTextUpdate = vi.fn();
mockOnTextDelete = vi.fn();
});
it("should render with empty text list", () => {
const { container } = render(TextManager, {
props: {
texts: [],
onTextAdd: mockOnTextAdd,
onTextUpdate: mockOnTextUpdate,
onTextDelete: mockOnTextDelete,
},
});
expect(container).toBeTruthy();
});
it("should render with existing texts", () => {
const mockTexts = [
{ id: "text-1", text: "First text" },
{ id: "text-2", text: "Second text" },
];
const { container } = render(TextManager, {
props: {
texts: mockTexts,
onTextAdd: mockOnTextAdd,
onTextUpdate: mockOnTextUpdate,
onTextDelete: mockOnTextDelete,
},
});
expect(container).toBeTruthy();
});
it("should call onTextAdd when add text form is submitted", async () => {
const { container } = render(TextManager, {
props: {
texts: [],
onTextAdd: mockOnTextAdd,
onTextUpdate: mockOnTextUpdate,
onTextDelete: mockOnTextDelete,
},
});
// Find text input and add button
const textInputs = container.querySelectorAll('input[type="text"], textarea');
const buttons = screen.getAllByRole("button");
if (textInputs.length > 0 && buttons.length > 0) {
await fireEvent.input(textInputs[0], { target: { value: "New text" } });
await fireEvent.click(buttons[0]);
expect(mockOnTextAdd).toHaveBeenCalledWith("New text", expect.any(Object));
}
});
it("should show error for empty text", async () => {
const { container } = render(TextManager, {
props: {
texts: [],
onTextAdd: mockOnTextAdd,
onTextUpdate: mockOnTextUpdate,
onTextDelete: mockOnTextDelete,
},
});
// Find add button and click without entering text
const buttons = screen.getAllByRole("button");
if (buttons.length > 0) {
await fireEvent.click(buttons[0]);
// Should not call onTextAdd for empty text
expect(mockOnTextAdd).not.toHaveBeenCalled();
}
});
it("should handle text update", async () => {
const mockTexts = [{ id: "text-1", text: "Original text" }];
const { container } = render(TextManager, {
props: {
texts: mockTexts,
onTextAdd: mockOnTextAdd,
onTextUpdate: mockOnTextUpdate,
onTextDelete: mockOnTextDelete,
},
});
// Find text inputs for existing texts
const textInputs = container.querySelectorAll('input[type="text"], textarea');
if (textInputs.length > 0) {
await fireEvent.input(textInputs[0], { target: { value: "Updated text" } });
// Should call onTextUpdate with the text ID and new text
expect(mockOnTextUpdate).toHaveBeenCalledWith("text-1", "Updated text");
}
});
it("should handle text deletion", async () => {
const mockTexts = [{ id: "text-1", text: "Text to delete" }];
render(TextManager, {
props: {
texts: mockTexts,
onTextAdd: mockOnTextAdd,
onTextUpdate: mockOnTextUpdate,
onTextDelete: mockOnTextDelete,
},
});
// Find delete button (might be an X button or similar)
const buttons = screen.getAllByRole("button");
const deleteButton = buttons.find(
(button) =>
button.textContent?.includes("Delete") ||
button.textContent?.includes("Remove") ||
button.textContent?.includes("×") ||
button.textContent?.includes("X"),
);
if (deleteButton) {
await fireEvent.click(deleteButton);
expect(mockOnTextDelete).toHaveBeenCalledWith("text-1");
}
});
it("should handle font size changes", async () => {
const { container } = render(TextManager, {
props: {
texts: [],
onTextAdd: mockOnTextAdd,
onTextUpdate: mockOnTextUpdate,
onTextDelete: mockOnTextDelete,
},
});
// Find font size input (number input)
const numberInputs = container.querySelectorAll('input[type="number"]');
if (numberInputs.length > 0) {
await fireEvent.input(numberInputs[0], { target: { value: "24" } });
// Font size change should be handled in the component
expect(container).toBeTruthy(); // Basic check that component still renders
}
});
it("should handle color changes", async () => {
const { container } = render(TextManager, {
props: {
texts: [],
onTextAdd: mockOnTextAdd,
onTextUpdate: mockOnTextUpdate,
onTextDelete: mockOnTextDelete,
},
});
// Find color input
const colorInputs = container.querySelectorAll('input[type="color"]');
if (colorInputs.length > 0) {
await fireEvent.input(colorInputs[0], { target: { value: "#ff0000" } });
// Color change should be handled in the component
expect(container).toBeTruthy(); // Basic check that component still renders
}
});
it("should handle text alignment changes", async () => {
const { container } = render(TextManager, {
props: {
texts: [],
onTextAdd: mockOnTextAdd,
onTextUpdate: mockOnTextUpdate,
onTextDelete: mockOnTextDelete,
},
});
// Look for alignment buttons or radio buttons
const radioButtons = screen.getAllByRole("radio");
if (radioButtons.length > 0) {
await fireEvent.click(radioButtons[0]);
expect(container).toBeTruthy(); // Basic check that component still renders
}
});
it("should call updateAllTextSettings when update all button is clicked", async () => {
const { updateAllTextSettings } = await import("$stores/panelStore");
render(TextManager, {
props: {
texts: [],
onTextAdd: mockOnTextAdd,
onTextUpdate: mockOnTextUpdate,
onTextDelete: mockOnTextDelete,
},
});
// Look for "Update All" or similar button
const buttons = screen.getAllByRole("button");
const updateAllButton = buttons.find(
(button) =>
button.textContent?.includes("Update All") ||
button.textContent?.includes("Apply to All") ||
button.textContent?.includes("Сохранить"),
);
if (updateAllButton) {
await fireEvent.click(updateAllButton);
expect(updateAllTextSettings).toHaveBeenCalled();
}
});
});
@@ -0,0 +1,10 @@
import CropInline from "$components/image/CropInline.svelte";
import { render } from "@testing-library/svelte";
import { describe, expect, it } from "vitest";
describe("CropInline.svelte", () => {
it("should render without crashing", () => {
const { container } = render(CropInline);
expect(container).toBeInTheDocument();
});
});
@@ -0,0 +1,11 @@
import ImageManager from "$components/image/ImageManager.svelte";
import { render } from "@testing-library/svelte";
import { describe, expect, it } from "vitest";
describe("ImageManager.svelte", () => {
it("should render without crashing", () => {
const { container } = render(ImageManager);
expect(container).toBeTruthy();
});
});
@@ -0,0 +1,25 @@
import AppHeader from "$components/layout/AppHeader.svelte";
import { themeState } from "$states/theme.svelte";
import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest";
describe("AppHeader.svelte", () => {
beforeEach(() => {
themeState.current = "dark";
});
it("should toggle theme on button click", async () => {
const user = userEvent.setup();
render(AppHeader);
const toggleButton = screen.getByRole("button", { name: /toggle theme/i });
themeState.current = "dark";
await user.click(toggleButton);
expect(themeState.current).toBe("light");
await user.click(toggleButton);
expect(themeState.current).toBe("dark");
});
});
+11
View File
@@ -0,0 +1,11 @@
import { render, screen } from "@testing-library/svelte";
import { describe, expect, it } from "vitest";
import CardTest from "./CardTest.svelte";
describe("Card.svelte", () => {
it("should render without crashing", () => {
render(CardTest);
expect(screen.getByText("Test Title")).toBeInTheDocument();
expect(screen.getByTestId("card-content")).toBeInTheDocument();
});
});
@@ -0,0 +1,7 @@
<script>
import Card from "$components/layout/Card.svelte";
</script>
<Card title="Test Title">
<div data-testid="card-content">Test content</div>
</Card>
@@ -0,0 +1,12 @@
import { render, screen } from "@testing-library/svelte";
import { describe, expect, it } from "vitest";
import InputGroupTest from "./InputGroupTest.svelte";
describe("InputGroup.svelte", () => {
it("should render without crashing", () => {
render(InputGroupTest);
expect(screen.getByTestId("input1")).toBeInTheDocument();
expect(screen.getByTestId("input2")).toBeInTheDocument();
expect(screen.getByTestId("button")).toBeInTheDocument();
});
});

Some files were not shown because too many files have changed in this diff Show More