@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
@@ -8,6 +8,7 @@ node_modules
|
|||||||
/.svelte-kit
|
/.svelte-kit
|
||||||
/build
|
/build
|
||||||
/coverage
|
/coverage
|
||||||
|
/.VSCodeCounter
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -22,3 +23,4 @@ Thumbs.db
|
|||||||
# Vite
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Package Managers
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
|
bun.lock
|
||||||
|
bun.lockb
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/static/
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"useTabs": false,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.svelte",
|
||||||
|
"options": {
|
||||||
|
"parser": "svelte"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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/**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.
|
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
Generated
+3435
-9
File diff suppressed because it is too large
Load Diff
+27
-3
@@ -9,6 +9,9 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"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",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:ui": "vitest --ui",
|
"test:ui": "vitest --ui",
|
||||||
@@ -16,9 +19,14 @@
|
|||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:unit": "vitest run tests/unit",
|
"test:unit": "vitest run tests/unit",
|
||||||
"test:integration": "vitest run tests/integration",
|
"test:integration": "vitest run tests/integration",
|
||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test",
|
||||||
|
"lint": "prettier --check . && eslint .",
|
||||||
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/compat": "^2.0.2",
|
||||||
|
"@eslint/js": "^9.39.2",
|
||||||
|
"@iconify-json/lucide": "^1.2.89",
|
||||||
"@playwright/test": "^1.58.1",
|
"@playwright/test": "^1.58.1",
|
||||||
"@sveltejs/adapter-auto": "^7.0.0",
|
"@sveltejs/adapter-auto": "^7.0.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
@@ -26,17 +34,33 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/svelte": "^5.3.1",
|
"@testing-library/svelte": "^5.3.1",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/node": "^25.1.0",
|
"@types/node": "^20",
|
||||||
"@vitest/coverage-istanbul": "^4.0.18",
|
"@vitest/coverage-istanbul": "^4.0.18",
|
||||||
|
"@vitest/eslint-plugin": "^1.6.9",
|
||||||
"@vitest/ui": "^4.0.18",
|
"@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",
|
"jsdom": "^28.0.0",
|
||||||
"konva": "^10.2.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": "^5.48.2",
|
||||||
"svelte-check": "^4.3.5",
|
"svelte-check": "^4.3.5",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.54.0",
|
||||||
|
"unplugin-icons": "^23.0.1",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.0.18",
|
||||||
|
"vitest-canvas-mock": "^1.1.3",
|
||||||
|
"web-animations-js": "^2.3.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/cropperjs": "^1.1.5",
|
"@types/cropperjs": "^1.1.5",
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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)
|
||||||
@@ -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. ⏳ Добавить социальные функции и облачное хранилище
|
|
||||||
@@ -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
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
@@ -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_
|
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -3,6 +3,19 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<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%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
@@ -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>
|
|
||||||
@@ -1,20 +1,97 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ImageUpload from "./ImageUpload.svelte";
|
import Card from "$components/layout/Card.svelte";
|
||||||
import ImageCropper from "./ImageCropper.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";
|
||||||
|
|
||||||
interface Props {
|
async function handlePaste(event: ClipboardEvent) {
|
||||||
currentStep: string;
|
let image = await uploadService.fromClipboard(event);
|
||||||
uploadedImage: string | undefined;
|
if (image.ok) {
|
||||||
onImageUpload: (image: string) => void;
|
imageState.fullImage = image.data;
|
||||||
onCropComplete: (croppedImage: string) => void;
|
}
|
||||||
onCropCancel: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { currentStep, uploadedImage, onImageUpload, onCropComplete, onCropCancel }: Props = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if currentStep === "upload"}
|
<Card title="Фоновое изображение">
|
||||||
<ImageUpload onImageSelect={onImageUpload} />
|
<div class="crop-editor">
|
||||||
{:else if currentStep === "crop" && uploadedImage}
|
<CropInline />
|
||||||
<ImageCropper imageSrc={uploadedImage} {onCropComplete} onCancel={onCropCancel} />
|
|
||||||
{/if}
|
<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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,30 +1,86 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
import { themeState } from "$states/theme.svelte";
|
||||||
onUploadNewImage?: () => void;
|
import Moon from "~icons/lucide/moon";
|
||||||
}
|
import Sun from "~icons/lucide/sun";
|
||||||
|
|
||||||
let { onUploadNewImage }: Props = $props();
|
function toggleTheme() {
|
||||||
|
themeState.toggle();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="app-header">
|
<header class="header">
|
||||||
<h1>Twitch Panels Creator</h1>
|
<div class="header-content">
|
||||||
</div>
|
<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>
|
<style>
|
||||||
.app-header {
|
.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;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
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 {
|
.header h1 {
|
||||||
margin: 0;
|
font-size: 20px;
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 600;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -1,414 +1,40 @@
|
|||||||
<script lang="ts">
|
<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 Button from "$components/ui/Button.svelte";
|
||||||
import { TYPOGRAPHY } from "$lib/constants";
|
import { textsState } from "$states/texts.svelte";
|
||||||
import type { TextAlign, TextItem } from "$lib/types/panel";
|
import Plus from "~icons/lucide/plus";
|
||||||
import { textSettingsStore, updateAllTextSettings } from "$stores/panelStore";
|
import TextInlineEdit from "./TextInlineEdit.svelte";
|
||||||
|
import TextInput from "./TextInput.svelte";
|
||||||
|
|
||||||
interface Props {
|
let text: string = $state("");
|
||||||
onTextAdd: (text: string, settings?: Partial<TextItem>) => void;
|
|
||||||
onTextUpdate: (id: string, text: string) => void;
|
function addText() {
|
||||||
onTextDelete: (id: string) => void;
|
textsState.addText(text);
|
||||||
texts: Array<{ id: string; text: string }>;
|
text = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onTextAdd, onTextUpdate, onTextDelete, texts }: Props = $props();
|
function deleteText(id: number) {
|
||||||
|
textsState.removeText(id);
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<div class="text-manager">
|
<Card title="Тексты панелей">
|
||||||
<div class="add-text-section">
|
<InputGroup>
|
||||||
<div class="input-group">
|
<TextInput ariaLabel="Input new text" bind:text onenter={addText} />
|
||||||
<input
|
<Button icon={Plus} ariaLabel="Add text" onclick={addText} />
|
||||||
type="text"
|
</InputGroup>
|
||||||
bind:value={newText}
|
<ul class="texts-list">
|
||||||
placeholder="Введите текст для панели (например: links, about me, projects...)"
|
{#each textsState.texts as { id }, idx (id)}
|
||||||
class="text-input"
|
<TextInlineEdit {id} bind:text={textsState.texts[idx].text} ondelete={() => deleteText(id)} />
|
||||||
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}
|
{/each}
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
</Card>
|
||||||
{/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>
|
<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 {
|
.texts-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 8px;
|
||||||
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>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
+75
-102
@@ -1,68 +1,67 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
type ButtonVariant = "primary" | "secondary" | "danger";
|
import type { Component } from "svelte";
|
||||||
type ButtonSize = "sm" | "md" | "lg";
|
import type { MouseEventHandler } from "svelte/elements";
|
||||||
type ButtonType = "button" | "submit" | "reset";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
variant?: ButtonVariant;
|
icon: Component;
|
||||||
size?: ButtonSize;
|
ariaLabel: string;
|
||||||
|
onclick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
type?: ButtonType;
|
label?: string;
|
||||||
fullWidth?: boolean;
|
type?: "primary" | "secondary" | "danger" | "outline" | "mini";
|
||||||
loading?: boolean;
|
extra?: "grow";
|
||||||
class?: string;
|
|
||||||
children?: any;
|
|
||||||
onclick?: (event: MouseEvent) => void;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
variant = "primary",
|
icon: Icon,
|
||||||
size = "md",
|
ariaLabel,
|
||||||
|
onclick = () => {},
|
||||||
disabled = false,
|
disabled = false,
|
||||||
type = "button",
|
label = "",
|
||||||
fullWidth = false,
|
type = "primary",
|
||||||
loading = false,
|
extra,
|
||||||
class: className = "",
|
|
||||||
children,
|
|
||||||
...restProps
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
{type}
|
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}
|
{disabled}
|
||||||
class:btn-primary={variant === "primary"}
|
{onclick}
|
||||||
class:btn-secondary={variant === "secondary"}
|
aria-label={ariaLabel}
|
||||||
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}
|
<Icon />
|
||||||
<span class="btn-spinner"></span>
|
{label}
|
||||||
{/if}
|
|
||||||
{@render children?.()}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-flex;
|
--btn-main: var(--action-primary);
|
||||||
align-items: center;
|
--btn-hover: var(--action-primary-hover);
|
||||||
justify-content: center;
|
padding: 10px 16px;
|
||||||
gap: 0.5rem;
|
border: 1px solid var(--btn-main);
|
||||||
padding: 0.75rem 1.5rem;
|
border-radius: var(--radius);
|
||||||
border: none;
|
font-size: 13px;
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: var(--transition);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: inherit;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
background-color: var(--btn-main);
|
||||||
|
color: var(--text-action);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn :global(svg) {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
@@ -70,73 +69,47 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sizes */
|
.btn:hover:not(:disabled) {
|
||||||
.btn-sm {
|
background-color: var(--btn-hover);
|
||||||
padding: 0.5rem 1rem;
|
box-shadow: var(--shadow-md);
|
||||||
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 {
|
.btn-secondary {
|
||||||
background: #6c757d;
|
--btn-main: var(--action-secondary);
|
||||||
color: white;
|
--btn-hover: var(--action-secondary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
.btn-mini {
|
||||||
background: #545b62;
|
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-danger {
|
||||||
background: #dc3545;
|
--btn-main: var(--danger-base);
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger:hover:not(:disabled) {
|
.grow {
|
||||||
background: #c82333;
|
flex-grow: 1;
|
||||||
}
|
justify-content: center;
|
||||||
|
|
||||||
/* 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>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
|
||||||
@@ -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"}
|
||||||
|
/>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
+54
-37
@@ -1,24 +1,14 @@
|
|||||||
// Application Constants
|
// Application Constants
|
||||||
// This file contains magic numbers and strings used in JavaScript/TypeScript logic
|
|
||||||
|
|
||||||
// ===== PANEL SETTINGS =====
|
// ===== PANEL SETTINGS =====
|
||||||
export const PANEL_SETTINGS = {
|
export const PANEL_SETTINGS = {
|
||||||
// Storage
|
|
||||||
STORAGE_KEY: "twitch-panels",
|
|
||||||
MAX_PANELS_COUNT: 50,
|
|
||||||
|
|
||||||
// Dimensions
|
|
||||||
PANEL_WIDTH: 320,
|
PANEL_WIDTH: 320,
|
||||||
PANEL_HEIGHT_DEFAULT: 100,
|
PANEL_HEIGHT_DEFAULT: 100,
|
||||||
PANEL_HEIGHT_MAX: 1000,
|
PANEL_HEIGHT_MAX: 200,
|
||||||
|
|
||||||
// Default values
|
|
||||||
DEFAULT_BACKGROUND_IMAGE: "./backgrounds/b1.jpg",
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ===== TYPOGRAPHY =====
|
// ===== TYPOGRAPHY =====
|
||||||
export const TYPOGRAPHY = {
|
export const TYPOGRAPHY = {
|
||||||
// Font families
|
|
||||||
FONT_FAMILY_DEFAULT: "Arial",
|
FONT_FAMILY_DEFAULT: "Arial",
|
||||||
FONT_FAMILIES: [
|
FONT_FAMILIES: [
|
||||||
"Arial",
|
"Arial",
|
||||||
@@ -34,52 +24,79 @@ export const TYPOGRAPHY = {
|
|||||||
// Font sizes for range inputs
|
// Font sizes for range inputs
|
||||||
FONT_SIZE_MIN: 10,
|
FONT_SIZE_MIN: 10,
|
||||||
FONT_SIZE_MAX: 72,
|
FONT_SIZE_MAX: 72,
|
||||||
FONT_SIZE_DEFAULT: 18,
|
FONT_SIZE_DEFAULT: 32,
|
||||||
|
|
||||||
// Text alignment
|
|
||||||
TEXT_ALIGN_LEFT: "left",
|
|
||||||
TEXT_ALIGN_CENTER: "center",
|
|
||||||
TEXT_ALIGN_RIGHT: "right",
|
|
||||||
|
|
||||||
// Text limits
|
// Text limits
|
||||||
MAX_TEXT_LENGTH: 100,
|
MAX_TEXT_LENGTH: 100,
|
||||||
|
|
||||||
// Padding for range inputs
|
// Padding for range inputs
|
||||||
PADDING_X_DEFAULT: 10,
|
PADDING_X_DEFAULT: 10,
|
||||||
PADDING_X_LARGE: 20,
|
PADDING_X_MIN: 0,
|
||||||
PADDING_X_MAX: 50,
|
PADDING_X_MAX: 100,
|
||||||
|
|
||||||
// Vertical offset for range inputs
|
// Vertical offset for range inputs
|
||||||
VERTICAL_OFFSET_MAX: 50,
|
OFFSET_Y_MAX: 100,
|
||||||
VERTICAL_OFFSET_MIN: -50,
|
OFFSET_Y_MIN: -100,
|
||||||
|
OFFSET_Y_DEFAULT: 0,
|
||||||
|
|
||||||
// Colors
|
// Colors
|
||||||
TEXT_COLOR_DEFAULT: "#ffffff",
|
TEXT_COLOR_DEFAULT: "#ffffff",
|
||||||
TEXT_COLOR_ERROR: "#c62828",
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ===== IMAGE SETTINGS =====
|
// ===== IMAGE SETTINGS =====
|
||||||
export const IMAGE_SETTINGS = {
|
export const IMAGE_SETTINGS = {
|
||||||
// File sizes
|
|
||||||
MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB
|
MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB
|
||||||
|
|
||||||
// Supported formats
|
|
||||||
SUPPORTED_FORMATS: ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"] as const,
|
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;
|
} as const;
|
||||||
|
|
||||||
// ===== UI SETTINGS =====
|
export const SlideDirection = {
|
||||||
export const UI_SETTINGS = {
|
NEXT: "next",
|
||||||
// Steps
|
PREV: "prev",
|
||||||
STEPS: {
|
|
||||||
UPLOAD: "upload",
|
|
||||||
CROP: "crop",
|
|
||||||
TEXT: "text",
|
|
||||||
} as const,
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ===== ERROR HANDLING =====
|
export type SlideDirectionType = (typeof SlideDirection)[keyof typeof SlideDirection];
|
||||||
export const ERROR_HANDLING = {
|
|
||||||
// Retry settings
|
export const TextAlign = {
|
||||||
MAX_RETRIES: 3,
|
LEFT: "left",
|
||||||
RETRY_DELAY_MS: 1000,
|
CENTER: "center",
|
||||||
|
RIGHT: "right",
|
||||||
} as const;
|
} 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;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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";
|
|
||||||
}
|
|
||||||
@@ -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 [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type HexColor = `#${string}`;
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
@@ -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>
|
||||||
+20
-118
@@ -1,121 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Panel, TextItem } from "$lib/types/panel";
|
import PanelBar from "$components/layout/PanelBar.svelte";
|
||||||
import type { Stage as KonvaStage } from "konva/lib/Stage";
|
import TextBar from "$components/layout/TextBar.svelte";
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<AppContainer
|
<div class="main-grid">
|
||||||
errorMessage={exportService.getErrorMessage() || undefined}
|
<TextBar />
|
||||||
{uploadedImage}
|
<PanelBar />
|
||||||
{texts}
|
</div>
|
||||||
{backgroundImage}
|
|
||||||
{panels}
|
<style>
|
||||||
onUploadNewImage={handleUploadNewImage}
|
.main-grid {
|
||||||
onImageUpload={handleImageUpload}
|
display: grid;
|
||||||
onCropComplete={handleCropComplete}
|
grid-template-columns: 1fr;
|
||||||
onCropCancel={handleCropCancel}
|
gap: 16px;
|
||||||
onTextAdd={handleAddText}
|
}
|
||||||
onTextUpdate={handleUpdateText}
|
|
||||||
onTextDelete={handleDeleteText}
|
@media (min-width: 1024px) {
|
||||||
onDownload={handleDownload}
|
.main-grid {
|
||||||
onDownloadAll={handleDownloadAll}
|
grid-template-columns: 1fr 1fr;
|
||||||
/>
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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());
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import type { Stage } from "svelte-konva";
|
||||||
|
|
||||||
|
export const konvaAllStagesState: Array<Stage> = $state([]);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { Stage } from "svelte-konva";
|
||||||
|
|
||||||
|
export class KonvaStage {
|
||||||
|
stage: Stage | undefined = $state(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const konvaStageState = new KonvaStage();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
@@ -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());
|
||||||
@@ -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());
|
||||||
@@ -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(),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -14,7 +14,9 @@ const config = {
|
|||||||
},
|
},
|
||||||
alias: {
|
alias: {
|
||||||
$components: "src/components",
|
$components: "src/components",
|
||||||
|
$routes: "src/routes",
|
||||||
$stores: "src/stores",
|
$stores: "src/stores",
|
||||||
|
$states: "src/states",
|
||||||
$services: "src/services",
|
$services: "src/services",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+3
-79
@@ -1,79 +1,3 @@
|
|||||||
import { vi } from "vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import "vitest-canvas-mock";
|
||||||
// Extend global type
|
import "web-animations-js";
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<script>
|
||||||
|
import InputGroup from "$components/layout/InputGroup.svelte";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<InputGroup>
|
||||||
|
<input type="text" data-testid="input1" />
|
||||||
|
<input type="text" data-testid="input2" />
|
||||||
|
<button data-testid="button">Кнопка</button>
|
||||||
|
</InputGroup>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import PanelBar from "$components/layout/PanelBar.svelte";
|
||||||
|
import { render } from "@testing-library/svelte";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("PanelBar.svelte", () => {
|
||||||
|
it("should render without crashing", () => {
|
||||||
|
const { container } = render(PanelBar);
|
||||||
|
expect(container).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user