style: add eslint and prettier, fix style errors

This commit is contained in:
2026-02-13 09:59:19 +05:00
parent 5d6159fe29
commit 387c1751c6
101 changed files with 14596 additions and 12946 deletions
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+65 -65
View File
@@ -1,65 +1,65 @@
name: Deploy SvelteKit name: Deploy SvelteKit
on: on:
push: push:
branches: [main, master] branches: [main, master]
paths: paths:
- "src/**" - "src/**"
- "package.json" - "package.json"
- "tsconfig.json" - "tsconfig.json"
- "svelte.config.js" - "svelte.config.js"
- "vite.config.js" - "vite.config.js"
- ".github/workflows/**" - ".github/workflows/**"
workflow_dispatch: workflow_dispatch:
permissions: permissions:
contents: read contents: read
pages: write pages: write
id-token: write id-token: write
concurrency: concurrency:
group: pages group: pages
cancel-in-progress: false cancel-in-progress: false
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "20" node-version: "20"
cache: "npm" cache: "npm"
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Build - name: Build
run: npm run build run: npm run build
- name: Clean _app directory - name: Clean _app directory
run: | run: |
rm -rf build/_app rm -rf build/_app
touch build/.nojekyll touch build/.nojekyll
- name: Setup Pages - name: Setup Pages
uses: actions/configure-pages@v4 uses: actions/configure-pages@v4
- name: Upload artifact - name: Upload artifact
uses: actions/upload-pages-artifact@v3 uses: actions/upload-pages-artifact@v3
with: with:
path: ./build path: ./build
deploy: deploy:
environment: environment:
name: github-pages name: github-pages
url: ${{ steps.deployment.outputs.page_url }} url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build needs: build
steps: steps:
- name: Deploy to GitHub Pages - name: Deploy to GitHub Pages
id: deployment id: deployment
uses: actions/deploy-pages@v4 uses: actions/deploy-pages@v4
+9
View File
@@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/
+16
View File
@@ -0,0 +1,16 @@
{
"useTabs": false,
"singleQuote": false,
"trailingComma": "all",
"endOfLine": "lf",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}
+20 -20
View File
@@ -1,20 +1,20 @@
{ {
"extends": ["stylelint-config-recommended", "stylelint-config-html/svelte"], "extends": ["stylelint-config-recommended", "stylelint-config-html/svelte"],
"rules": { "rules": {
"selector-pseudo-class-no-unknown": [ "selector-pseudo-class-no-unknown": [
true, true,
{ {
"ignorePseudoClasses": ["global"] "ignorePseudoClasses": ["global"]
} }
] ]
}, },
"ignoreFiles": [ "ignoreFiles": [
".github/**/*", ".github/**/*",
".svelte-kit/**/*", ".svelte-kit/**/*",
"build/**/*", "build/**/*",
"coverage/**/*", "coverage/**/*",
"dist/**/*", "dist/**/*",
"node_modules/**/*", "node_modules/**/*",
"reference/**/*" "reference/**/*"
] ]
} }
+40
View File
@@ -0,0 +1,40 @@
import { includeIgnoreFile } from "@eslint/compat";
import js from "@eslint/js";
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",
},
},
{
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: [".svelte"],
parser: ts.parser,
svelteConfig,
},
},
},
);
+7189 -5723
View File
File diff suppressed because it is too large Load Diff
+73 -61
View File
@@ -1,61 +1,73 @@
{ {
"name": "twitch-panels", "name": "twitch-panels",
"private": true, "private": true,
"version": "0.0.1", "version": "0.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"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}", "lint:css": "stylelint '**/*.{css,svelte}",
"check:css": "node scripts/css-vars.js", "check:css": "node scripts/css-vars.js",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:all": "npm run check && npm run check:css && npm run lint:css && npm run lint && npm run test:run",
"test": "vitest", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test:ui": "vitest --ui", "test": "vitest",
"test:run": "vitest run", "test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage", "test:run": "vitest run",
"test:unit": "vitest run tests/unit", "test:coverage": "vitest run --coverage",
"test:integration": "vitest run tests/integration", "test:unit": "vitest run tests/unit",
"test:e2e": "playwright test" "test:integration": "vitest run tests/integration",
}, "test:e2e": "playwright test",
"devDependencies": { "lint": "prettier --check . && eslint .",
"@iconify-json/lucide": "^1.2.89", "format": "prettier --write ."
"@playwright/test": "^1.58.1", },
"@sveltejs/adapter-auto": "^7.0.0", "devDependencies": {
"@sveltejs/adapter-static": "^3.0.10", "@eslint/compat": "^2.0.2",
"@sveltejs/kit": "^2.50.1", "@eslint/js": "^9.39.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@iconify-json/lucide": "^1.2.89",
"@testing-library/jest-dom": "^6.9.1", "@playwright/test": "^1.58.1",
"@testing-library/svelte": "^5.3.1", "@sveltejs/adapter-auto": "^7.0.0",
"@testing-library/user-event": "^14.6.1", "@sveltejs/adapter-static": "^3.0.10",
"@types/file-saver": "^2.0.7", "@sveltejs/kit": "^2.50.1",
"@types/node": "^25.1.0", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@vitest/coverage-istanbul": "^4.0.18", "@testing-library/jest-dom": "^6.9.1",
"@vitest/ui": "^4.0.18", "@testing-library/svelte": "^5.3.1",
"glob": "^13.0.2", "@testing-library/user-event": "^14.6.1",
"jsdom": "^28.0.0", "@types/file-saver": "^2.0.7",
"konva": "^10.2.0", "@types/node": "^20",
"stylelint": "^17.2.0", "@vitest/coverage-istanbul": "^4.0.18",
"stylelint-config-html": "^1.1.0", "@vitest/ui": "^4.0.18",
"stylelint-config-recommended": "^18.0.0", "eslint": "^9.39.2",
"svelte": "^5.48.2", "eslint-config-prettier": "^10.1.8",
"svelte-check": "^4.3.5", "eslint-plugin-svelte": "^3.14.0",
"typescript": "^5.9.3", "glob": "^13.0.2",
"unplugin-icons": "^23.0.1", "globals": "^17.3.0",
"vite": "^7.3.1", "jsdom": "^28.0.0",
"vitest": "^4.0.18", "konva": "^10.2.0",
"vitest-canvas-mock": "^1.1.3", "prettier": "^3.8.1",
"web-animations-js": "^2.3.2" "prettier-plugin-svelte": "^3.4.1",
}, "stylelint": "^17.2.0",
"dependencies": { "stylelint-config-html": "^1.1.0",
"@types/cropperjs": "^1.1.5", "stylelint-config-recommended": "^18.0.0",
"@types/uuid": "^10.0.0", "svelte": "^5.48.2",
"cropperjs": "^2.1.0", "svelte-check": "^4.3.5",
"file-saver": "^2.0.5", "typescript": "^5.9.3",
"jszip": "^3.10.1", "typescript-eslint": "^8.54.0",
"svelte-konva": "^1.0.1", "unplugin-icons": "^23.0.1",
"uuid": "^13.0.0" "vite": "^7.3.1",
} "vitest": "^4.0.18",
} "vitest-canvas-mock": "^1.1.3",
"web-animations-js": "^2.3.2"
},
"dependencies": {
"@types/cropperjs": "^1.1.5",
"@types/uuid": "^10.0.0",
"cropperjs": "^2.1.0",
"file-saver": "^2.0.5",
"jszip": "^3.10.1",
"svelte-konva": "^1.0.1",
"uuid": "^13.0.0"
}
}
+330 -330
View File
@@ -1,330 +1,330 @@
# Функциональный план (Product & UX Roadmap) # Функциональный план (Product & UX Roadmap)
## Twitch Panels Creator ## Twitch Panels Creator
**Анализ версии:** 0.0.1 **Анализ версии:** 0.0.1
**Дата анализа:** 2025-02-09 **Дата анализа:** 2025-02-09
**Технологии:** Svelte 5, TypeScript, Konva.js, Cropper.js **Технологии:** Svelte 5, TypeScript, Konva.js, Cropper.js
--- ---
## 1. Текущие возможности (глазами пользователя) ## 1. Текущие возможности (глазами пользователя)
### ✅ Реализованный функционал ### ✅ Реализованный функционал
#### 1.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) **Файлы:** [`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 - Добавление текстовых панелей через инпут + кнопку/Enter
- Редактирование текста инлайн (двойной клик или прямое редактирование) - Редактирование текста инлайн (двойной клик или прямое редактирование)
- Удаление отдельных текстов - Удаление отдельных текстов
- Глобальные настройки текста (размер, шрифт, цвет, выравнивание, отступы, смещение) - Глобальные настройки текста (размер, шрифт, цвет, выравнивание, отступы, смещение)
- **Приоритет:** High - это основной рабочий поток - **Приоритет:** High - это основной рабочий поток
#### 1.2 Система превью панелей #### 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) **Файлы:** [`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") - Навигация между панелями (стрелки + индикатор "N / M")
- Просмотр всех панелей одновременно (в скрытом контейнере для экспорта) - Просмотр всех панелей одновременно (в скрытом контейнере для экспорта)
- Плавные переходы между панелями (fly transition) - Плавные переходы между панелями (fly transition)
- **Приоритет:** High - критично для UX - **Приоритет:** High - критично для UX
#### 1.3 Экспорт панелей #### 1.3 Экспорт панелей
**Файлы:** [`src/services/downloadService.ts`](src/services/downloadService.ts) **Файлы:** [`src/services/downloadService.ts`](src/services/downloadService.ts)
- Экспорт отдельной панели в PNG (320x100px) - Экспорт отдельной панели в PNG (320x100px)
- Массовый экспорт всех панелей в ZIP-архив - Массовый экспорт всех панелей в ZIP-архив
- Использование file-saver и JSZip - Использование file-saver и JSZip
- **Приоритет:** High - финальная цель пользователя - **Приоритет:** High - финальная цель пользователя
#### 1.4 Система фоновых изображений (частичная) #### 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) **Файлы:** [`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 кнопки) - Загрузка изображения по URL (только UI кнопки)
- Визуальный интерфейс для обрезки (crop box с handles) - Визуальный интерфейс для обрезки (crop box с handles)
- Настройки яркости и контраста (только UI слайдеры) - Настройки яркости и контраста (только UI слайдеры)
- Автозагрузка дефолтного фона при старте - Автозагрузка дефолтного фона при старте
- **Приоритет:** Low - нерабочая система - **Приоритет:** Low - нерабочая система
#### 1.5 Базовый UI/UX #### 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) **Файлы:** [`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) - Кнопки 5 типов (primary, secondary, outline, danger, mini)
- Слайдеры с отображением значения - Слайдеры с отображением значения
- Цветовой пикер - Цветовой пикер
- Выбор шрифта из 8 системных - Выбор шрифта из 8 системных
- Три кнопки выравнивания - Три кнопки выравнивания
- Темная/светлая тема - Темная/светлая тема
- **Приоритет:** Medium - работает, но можно улучшить - **Приоритет:** Medium - работает, но можно улучшить
--- ---
## 2. Low-hanging fruit (Ближайшие улучшения) ## 2. Low-hanging fruit (Ближайшие улучшения)
### 2.1 Завершение системы изображений (High Priority) ### 2.1 Завершение системы изображений (High Priority)
**Проблема:** [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte) имеет UI, но нет логики загрузки/обработки. **Проблема:** [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte) имеет UI, но нет логики загрузки/обработки.
**Требуемые правки:** **Требуемые правки:**
1. **Реализовать загрузку изображений** в [`src/services/imageService.ts`](src/services/imageService.ts): 1. **Реализовать загрузку изображений** в [`src/services/imageService.ts`](src/services/imageService.ts):
- Drag & drop поддержка - Drag & drop поддержка
- Paste из буфера обмена - Paste из буфера обмена
- Загрузка по URL с валидацией - Загрузка по URL с валидацией
- Валидация форматов (JPEG, PNG, WebP, GIF) и размера (10MB max) - Валидация форматов (JPEG, PNG, WebP, GIF) и размера (10MB max)
- **Контракт:** `imageService.uploadImage(source: File | string): Promise<ImageConfig>` - **Контракт:** `imageService.uploadImage(source: File | string): Promise<ImageConfig>`
2. **Интегрировать cropperjs** в [`src/components/image/CropInline.svelte`](src/components/image/CropInline.svelte): 2. **Интегрировать cropperjs** в [`src/components/image/CropInline.svelte`](src/components/image/CropInline.svelte):
- Инициализация cropper.js на canvas - Инициализация cropper.js на canvas
- Привязка crop box к данным состояния - Привязка crop box к данным состояния
- Реализовать drag & drop для перемещения - Реализовать drag & drop для перемещения
- Реализовать resize через 8 handles - Реализовать resize через 8 handles
- **Контракт:** `onCropChange(crop: { left, top, right, bottom })` - **Контракт:** `onCropChange(crop: { left, top, right, bottom })`
3. **Применить фильтры яркости/контраста**: 3. **Применить фильтры яркости/контраста**:
- В [`src/states/imageConfig.svelte.ts`](src/states/imageConfig.svelte.ts) добавить `brightness: number`, `contrast: number` - В [`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) - Применить CSS filters к Konva Image в [`src/components/panel/Preview.svelte`](src/components/panel/Preview.svelte)
- **Контракт:** `filter: brightness(${brightness}%) contrast(${contrast}%)` - **Контракт:** `filter: brightness(${brightness}%) contrast(${contrast}%)`
4. **Сохранить обрезанное изображение**: 4. **Сохранить обрезанное изображение**:
- Метод `imageConfigState.applyCrop()` должен обновлять `image` с обрезанными данными - Метод `imageConfigState.applyCrop()` должен обновлять `image` с обрезанными данными
- Использовать canvas для actual cropping - Использовать canvas для actual cropping
### 2.2 Валидация и состояния загрузки (Medium Priority) ### 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) - [`src/components/text/TextInput.svelte`](src/components/text/TextInput.svelte): добавить валидацию длины (max 100 chars из [`src/lib/constants.ts`](src/lib/constants.ts):32)
- Показать ошибку при превышении длины - Показать ошибку при превышении длины
- Добавить счетчик символов "3/100" - Добавить счетчик символов "3/100"
**Изображения:** **Изображения:**
- [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte): показать состояние загрузки (spinner) при загрузке по URL - [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte): показать состояние загрузки (spinner) при загрузке по URL
- Показать ошибку при неудачной загрузке - Показать ошибку при неудачной загрузке
- Валидация формата/размера до загрузки - Валидация формата/размера до загрузки
**Экспорт:** **Экспорт:**
- [`src/services/downloadService.ts`](src/services/downloadService.ts): показать прогресс-бар при `downloadAll()` - [`src/services/downloadService.ts`](src/services/downloadService.ts): показать прогресс-бар при `downloadAll()`
- Отключить кнопки во время экспорта - Отключить кнопки во время экспорта
- Показать toast уведомление об успехе/ошибке - Показать toast уведомление об успехе/ошибке
### 2.3 Пустые состояния (Medium Priority) ### 2.3 Пустые состояния (Medium Priority)
**Списки:** **Списки:**
- [`src/components/text/TextManager.svelte`](src/components/text/TextManager.svelte): когда `textsState.texts.length === 0`, показать красивый empty state вместо пустого UL - [`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/panel/PreviewManager.svelte`](src/components/panel/PreviewManager.svelte): уже есть empty state, но можно улучшить (иконка + текст + CTA)
**Изображение:** **Изображение:**
- [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte): когда нет изображения, показать placeholder с инструкцией - [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte): когда нет изображения, показать placeholder с инструкцией
### 2.4 Улучшение доступности (Medium Priority) ### 2.4 Улучшение доступности (Medium Priority)
**Файлы для правок:** **Файлы для правок:**
- Все кнопки уже имеют `aria-label` - Все кнопки уже имеют `aria-label`
- [`src/components/text/TextInput.svelte`](src/components/text/TextInput.svelte): добавить `aria-describedby` для валидации - [`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 (уже есть) - [`src/components/text/TextInlineEdit.svelte`](src/components/text/TextInlineEdit.svelte): добавить `aria-label` на delete button (уже есть)
- Добавить `role="region"` и `aria-label` на карточки - Добавить `role="region"` и `aria-label` на карточки
- Убедиться, что фокусный порядок логичен (Tab navigation) - Убедиться, что фокусный порядок логичен (Tab navigation)
--- ---
## 3. Масштабирование (Крупные модули/интеграции) ## 3. Масштабирование (Крупные модули/интеграции)
### 3.1 Сохранение и загрузка проектов (High Priority) ### 3.1 Сохранение и загрузка проектов (High Priority)
**Проблема:** Нет persistence, пользователь теряет данные при перезагрузке. **Проблема:** Нет persistence, пользователь теряет данные при перезагрузке.
**Решение:** **Решение:**
1. **Создать [`src/services/storageService.ts`](src/services/storageService.ts):** 1. **Создать [`src/services/storageService.ts`](src/services/storageService.ts):**
- `saveProject(project: Project): Promise<void>` → localStorage - `saveProject(project: Project): Promise<void>` → localStorage
- `loadProject(id: string): Project | null` - `loadProject(id: string): Project | null`
- `listProjects(): ProjectInfo[]` - `listProjects(): ProjectInfo[]`
- `deleteProject(id: string)` - `deleteProject(id: string)`
- **Project тип:** `{ id, name, createdAt, texts, imageConfig, textConfig }` - **Project тип:** `{ id, name, createdAt, texts, imageConfig, textConfig }`
2. **Добавить UI для управления проектами:** 2. **Добавить UI для управления проектами:**
- [`src/components/layout/AppHeader.svelte`](src/components/layout/AppHeader.svelte): добавить кнопку "Проекты" → открывает модалку - [`src/components/layout/AppHeader.svelte`](src/components/layout/AppHeader.svelte): добавить кнопку "Проекты" → открывает модалку
- [`src/components/project/ProjectManager.svelte`](src/components/project/ProjectManager.svelte) (новый): - [`src/components/project/ProjectManager.svelte`](src/components/project/ProjectManager.svelte) (новый):
- Список сохраненных проектов - Список сохраненных проектов
- Создание/переименование/удаление - Создание/переименование/удаление
- Экспорт/импорт JSON (для бэкапа) - Экспорт/импорт JSON (для бэкапа)
3. **Автосохранение:** 3. **Автосохранение:**
- В [`src/routes/+layout.svelte`](src/routes/+layout.svelte): `$effect` на изменениях состояний → debounced save - В [`src/routes/+layout.svelte`](src/routes/+layout.svelte): `$effect` на изменениях состояний → debounced save
- Восстановление при загрузке страницы - Восстановление при загрузке страницы
**User Retention impact:** Высокий - пользователи смогут возвращаться к своим работам. **User Retention impact:** Высокий - пользователи смогут возвращаться к своим работам.
### 3.2 Расширенные настройки текста (Medium Priority) ### 3.2 Расширенные настройки текста (Medium Priority)
**Файлы для модификации:** [`src/states/textConfig.svelte.ts`](src/states/textConfig.svelte.ts), [`src/components/text/TextConfig.svelte`](src/components/text/TextConfig.svelte) **Файлы для модификации:** [`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 }` - **Тень текста:** `textShadow: { offsetX, offsetY, blur, color }`
- **Прозрачность:** `opacity: number` (0-100) - **Прозрачность:** `opacity: number` (0-100)
- **Градиент:** `gradient: { type: 'linear' | 'radial', colors: HexColor[], angle? }` (сложнее) - **Градиент:** `gradient: { type: 'linear' | 'radial', colors: HexColor[], angle? }` (сложнее)
- **Несколько текстовых блоков на панели:** потребует переархитектуры Preview.svelte - **Несколько текстовых блоков на панели:** потребует переархитектуры Preview.svelte
**Приоритет:** Medium - улучшает качество панелей, но требует работы с Konva. **Приоритет:** Medium - улучшает качество панелей, но требует работы с Konva.
### 3.3 Расширенные настройки изображений (Medium Priority) ### 3.3 Расширенные настройки изображений (Medium Priority)
**Файлы:** [`src/states/imageConfig.svelte.ts`](src/states/imageConfig.svelte.ts), [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte) **Файлы:** [`src/states/imageConfig.svelte.ts`](src/states/imageConfig.svelte.ts), [`src/components/image/ImageManager.svelte`](src/components/image/ImageManager.svelte)
Добавить: Добавить:
- **Фильтры:** blur, sepia, saturate, grayscale - **Фильтры:** blur, sepia, saturate, grayscale
- **Наложение:** возможность добавить второй слой изображения - **Наложение:** возможность добавить второй слой изображения
- **Режимы наложения:** multiply, screen, overlay (CSS blend modes) - **Режимы наложения:** multiply, screen, overlay (CSS blend modes)
**Приоритет:** Medium - улучшает креативность, но сложная реализация. **Приоритет:** Medium - улучшает креативность, но сложная реализация.
### 3.4 Пресеты и шаблоны (Low Priority) ### 3.4 Пресеты и шаблоны (Low Priority)
**Новые файлы:** **Новые файлы:**
- [`src/services/templateService.ts`](src/services/templateService.ts) - [`src/services/templateService.ts`](src/services/templateService.ts)
- [`src/components/template/TemplateGallery.svelte`](src/components/template/TemplateGallery.svelte) - [`src/components/template/TemplateGallery.svelte`](src/components/template/TemplateGallery.svelte)
**Функционал:** **Функционал:**
- Предустановленные шаблоны (разные стили текста/изображений) - Предустановленные шаблоны (разные стили текста/изображений)
- Сохранение пользовательских пресетов - Сохранение пользовательских пресетов
- Применение пресета к текущему проекту - Применение пресета к текущему проекту
**Приоритет:** Low - nice-to-have, не критично для MVP. **Приоритет:** Low - nice-to-have, не критично для MVP.
### 3.5 Интеграция с Twitch API (Future) ### 3.5 Интеграция с Twitch API (Future)
**Возможные интеграции:** **Возможные интеграции:**
- Прямая загрузка панелей на Twitch через API - Прямая загрузка панелей на Twitch через API
- Синхронизация с существующими панелями - Синхронизация с существующими панелями
- Планирование публикации - Планирование публикации
**Приоритет:** Low - требует OAuth, сложная интеграция. **Приоритет:** Low - требует OAuth, сложная интеграция.
--- ---
## 4. User Retention (Повторное использование) ## 4. User Retention (Повторное использование)
### 4.1 Сохранение проектов (см. 3.1) - HIGH ### 4.1 Сохранение проектов (см. 3.1) - HIGH
### 4.2 Настройки пользователя (Medium Priority) ### 4.2 Настройки пользователя (Medium Priority)
**Файлы:** [`src/states/theme.svelte.ts`](src/states/theme.svelte.ts) уже есть, но нет сохранения. **Файлы:** [`src/states/theme.svelte.ts`](src/states/theme.svelte.ts) уже есть, но нет сохранения.
**Добавить:** **Добавить:**
- Сохранение темы в localStorage (частично есть в [`src/routes/+layout.svelte`](src/routes/+layout.svelte):15) - Сохранение темы в localStorage (частично есть в [`src/routes/+layout.svelte`](src/routes/+layout.svelte):15)
- Сохранение предпочитаемого шрифта - Сохранение предпочитаемого шрифта
- Сохранение последних использованных цветов - Сохранение последних использованных цветов
- **Файл:** [`src/services/preferencesService.ts`](src/services/preferencesService.ts) - **Файл:** [`src/services/preferencesService.ts`](src/services/preferencesService.ts)
### 4.3 Экспорт в разные форматы (Medium Priority) ### 4.3 Экспорт в разные форматы (Medium Priority)
**Текущее состояние:** Только PNG. **Текущее состояние:** Только PNG.
**Добавить:** **Добавить:**
- Экспорт в JPEG (с настройкой качества) - Экспорт в JPEG (с настройкой качества)
- Экспорт в WebP (современный формат) - Экспорт в WebP (современный формат)
- Экспорт в PDF (для печати) - Экспорт в PDF (для печати)
- **Файл:** [`src/services/exportService.ts`](src/services/exportService.ts) (расширить downloadService) - **Файл:** [`src/services/exportService.ts`](src/services/exportService.ts) (расширить downloadService)
**Приоритет:** Medium - увеличивает полезность. **Приоритет:** Medium - увеличивает полезность.
### 4.4 История изменений (Low Priority) ### 4.4 История изменений (Low Priority)
**Новый файл:** [`src/services/historyService.ts`](src/services/historyService.ts) **Новый файл:** [`src/services/historyService.ts`](src/services/historyService.ts)
**Функционал:** **Функционал:**
- Undo/Redo для текстов и настроек - Undo/Redo для текстов и настроек
- Хранение N последних состояний - Хранение N последних состояний
- Горячие клавиши Ctrl+Z / Ctrl+Y - Горячие клавиши Ctrl+Z / Ctrl+Y
**Приоритет:** Low - удобно, но не обязательно. **Приоритет:** Low - удобно, но не обязательно.
### 4.5 Совместная работа (Future) ### 4.5 Совместная работа (Future)
**Сложная интеграция:** **Сложная интеграция:**
- Share link с состоянием (закодировать в URL) - Share link с состоянием (закодировать в URL)
- Real-time collaboration (WebSocket) - Real-time collaboration (WebSocket)
- Комментарии/ревью - Комментарии/ревью
**Приоритет:** Very Low - далеко от MVP. **Приоритет:** Very Low - далеко от MVP.
--- ---
## 5. Приоритизация по фазам ## 5. Приоритизация по фазам
### Фаза 1: Завершение базового функционала (2-3 недели) ### Фаза 1: Завершение базового функционала (2-3 недели)
**Цель:** Приложение должно работать end-to-end **Цель:** Приложение должно работать end-to-end
1. Полная реализация системы изображений (2.1) - **High** 1. Полная реализация системы изображений (2.1) - **High**
2. Сохранение проектов (3.1) - **High** 2. Сохранение проектов (3.1) - **High**
3. Валидация и состояния загрузки (2.2) - **Medium** 3. Валидация и состояния загрузки (2.2) - **Medium**
4. Пустые состояния (2.3) - **Medium** 4. Пустые состояния (2.3) - **Medium**
5. Настройки пользователя (4.2) - **Medium** 5. Настройки пользователя (4.2) - **Medium**
**Критерий успеха:** Пользователь может создать, сохранить и загрузить проект с изображением и текстом. **Критерий успеха:** Пользователь может создать, сохранить и загрузить проект с изображением и текстом.
### Фаза 2: Улучшение UX и расширение (3-4 недели) ### Фаза 2: Улучшение UX и расширение (3-4 недели)
1. Расширенные настройки текста (3.2) - **Medium** 1. Расширенные настройки текста (3.2) - **Medium**
2. Расширенные настройки изображений (3.3) - **Medium** 2. Расширенные настройки изображений (3.3) - **Medium**
3. Экспорт в разные форматы (4.3) - **Medium** 3. Экспорт в разные форматы (4.3) - **Medium**
4. Улучшение доступности (2.4) - **Medium** 4. Улучшение доступности (2.4) - **Medium**
5. Пресеты и шаблоны (3.4) - **Low** 5. Пресеты и шаблоны (3.4) - **Low**
**Критерий успеха:** Пользователь может создавать сложные панели с эффектами и быстро повторять стили. **Критерий успеха:** Пользователь может создавать сложные панели с эффектами и быстро повторять стили.
### Фаза 3: Продвинутые фичи (4+ недели) ### Фаза 3: Продвинутые фичи (4+ недели)
1. История изменений (4.4) - **Low** 1. История изменений (4.4) - **Low**
2. Интеграция с Twitch API (3.5) - **Low** 2. Интеграция с Twitch API (3.5) - **Low**
3. Совместная работа (4.5) - **Very Low** 3. Совместная работа (4.5) - **Very Low**
--- ---
## 6. Метрики успеха ## 6. Метрики успеха
- **Активация:** >70% пользователей создают хотя бы 1 панель - **Активация:** >70% пользователей создают хотя бы 1 панель
- **Сохранение:** >30% пользователей сохраняют проект - **Сохранение:** >30% пользователей сохраняют проект
- **Экспорт:** >50% пользователей экспортируют хотя бы 1 панель - **Экспорт:** >50% пользователей экспортируют хотя бы 1 панель
- **Время на создание:** <2 минут для набора из 5 панелей - **Время на создание:** <2 минут для набора из 5 панелей
- **Retention week 1:** >40% возвращаются - **Retention week 1:** >40% возвращаются
--- ---
## 7. Риски и ограничения ## 7. Риски и ограничения
1. **Сложность Konva:** Ограниченная кастомизация текста (несколько блоков на панели потребует переписывания Preview.svelte) 1. **Сложность Konva:** Ограниченная кастомизация текста (несколько блоков на панели потребует переписывания Preview.svelte)
2. **Производительность:** Множество Konva stages (konvaAllStages) могут тормозить при 50+ панелях 2. **Производительность:** Множество Konva stages (konvaAllStages) могут тормозить при 50+ панелях
3. **Браузерные ограничения:** localStorage 5-10MB, может не хватить для изображений 3. **Браузерные ограничения:** localStorage 5-10MB, может не хватить для изображений
4. **Cropper.js:** Требует доработки для работы с canvas/Konva 4. **Cropper.js:** Требует доработки для работы с canvas/Konva
--- ---
**Следующие шаги:** **Следующие шаги:**
1. Утвердить функциональный план 1. Утвердить функциональный план
2. Перейти к техническому плану (ENGINEERING_IMPROVEMENTS.md) 2. Перейти к техническому плану (ENGINEERING_IMPROVEMENTS.md)
3. Начать реализацию Фазы 1 (начиная с imageService) 3. Начать реализацию Фазы 1 (начиная с imageService)
+1145 -1145
View File
File diff suppressed because it is too large Load Diff
+288 -250
View File
@@ -1,250 +1,288 @@
<!doctype html> <!doctype html>
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Twitch Panels Creator</title> <title>Twitch Panels Creator</title>
<link rel="stylesheet" href="styles.css" /> <link rel="stylesheet" href="styles.css" />
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<header class="header"> <header class="header">
<div class="header-content"> <div class="header-content">
<h1>Twitch Panels</h1> <h1>Twitch Panels</h1>
<button id="themeToggle" class="theme-toggle" aria-label="Toggle theme"> <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"> <svg
<circle cx="12" cy="12" r="5"></circle> class="sun-icon"
<line x1="12" y1="1" x2="12" y2="3"></line> viewBox="0 0 24 24"
<line x1="12" y1="21" x2="12" y2="23"></line> fill="none"
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line> stroke="currentColor"
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line> stroke-width="2"
<line x1="1" y1="12" x2="3" y2="12"></line> >
<line x1="21" y1="12" x2="23" y2="12"></line> <circle cx="12" cy="12" r="5"></circle>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line> <line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line> <line x1="12" y1="21" x2="12" y2="23"></line>
</svg> <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<svg class="moon-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path> <line x1="1" y1="12" x2="3" y2="12"></line>
</svg> <line x1="21" y1="12" x2="23" y2="12"></line>
</button> <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
</div> <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</header> </svg>
<svg
<div class="main-grid"> class="moon-icon"
<!-- Left Column --> viewBox="0 0 24 24"
<div class="left-column"> fill="none"
<section class="card"> stroke="currentColor"
<h2 class="card-title">Тексты панелей</h2> stroke-width="2"
>
<div class="input-group"> <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
<input type="text" id="panelTextInput" placeholder="Введите текст..." class="text-input" /> </svg>
<button id="addTextBtn" class="btn btn-primary"> </button>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> </div>
<line x1="12" y1="5" x2="12" y2="19"></line> </header>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg> <div class="main-grid">
</button> <!-- Left Column -->
</div> <div class="left-column">
<section class="card">
<div id="textsList" class="texts-list"></div> <h2 class="card-title">Тексты панелей</h2>
</section>
<div class="input-group">
<section class="card"> <input
<h2 class="card-title">Настройки текста</h2> type="text"
id="panelTextInput"
<div class="settings-grid"> placeholder="Введите текст..."
<div class="setting-row"> class="text-input"
<label class="setting-label">Размер</label> />
<div class="setting-control"> <button id="addTextBtn" class="btn btn-primary">
<input type="range" id="fontSize" min="12" max="48" value="18" class="slider" /> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<span class="value-display" id="fontSizeValue">18</span> <line x1="12" y1="5" x2="12" y2="19"></line>
</div> <line x1="5" y1="12" x2="19" y2="12"></line>
</div> </svg>
</button>
<div class="setting-row"> </div>
<label class="setting-label">Шрифт</label>
<select id="fontFamily" class="select-input"> <div id="textsList" class="texts-list"></div>
<option value="Arial">Arial</option> </section>
<option value="Verdana">Verdana</option>
<option value="Georgia">Georgia</option> <section class="card">
<option value="Times New Roman">Times New Roman</option> <h2 class="card-title">Настройки текста</h2>
<option value="Courier New">Courier New</option>
<option value="Impact">Impact</option> <div class="settings-grid">
<option value="Comic Sans MS">Comic Sans MS</option> <div class="setting-row">
<option value="Trebuchet MS">Trebuchet MS</option> <label class="setting-label">Размер</label>
</select> <div class="setting-control">
</div> <input type="range" id="fontSize" min="12" max="48" value="18" class="slider" />
<span class="value-display" id="fontSizeValue">18</span>
<div class="setting-row"> </div>
<label class="setting-label">Цвет</label> </div>
<div class="setting-control">
<input type="color" id="textColor" value="#ffffff" class="color-input" /> <div class="setting-row">
<span class="color-value" id="textColorValue">#ffffff</span> <label class="setting-label">Шрифт</label>
</div> <select id="fontFamily" class="select-input">
</div> <option value="Arial">Arial</option>
<option value="Verdana">Verdana</option>
<div class="setting-row"> <option value="Georgia">Georgia</option>
<label class="setting-label">Выравнивание</label> <option value="Times New Roman">Times New Roman</option>
<div class="alignment-buttons"> <option value="Courier New">Courier New</option>
<button class="align-btn active" data-align="left"> <option value="Impact">Impact</option>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <option value="Comic Sans MS">Comic Sans MS</option>
<line x1="17" y1="10" x2="3" y2="10"></line> <option value="Trebuchet MS">Trebuchet MS</option>
<line x1="21" y1="6" x2="3" y2="6"></line> </select>
<line x1="21" y1="14" x2="3" y2="14"></line> </div>
<line x1="17" y1="18" x2="3" y2="18"></line>
</svg> <div class="setting-row">
</button> <label class="setting-label">Цвет</label>
<button class="align-btn" data-align="center"> <div class="setting-control">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <input type="color" id="textColor" value="#ffffff" class="color-input" />
<line x1="18" y1="10" x2="6" y2="10"></line> <span class="color-value" id="textColorValue">#ffffff</span>
<line x1="21" y1="6" x2="3" y2="6"></line> </div>
<line x1="21" y1="14" x2="3" y2="14"></line> </div>
<line x1="18" y1="18" x2="6" y2="18"></line>
</svg> <div class="setting-row">
</button> <label class="setting-label">Выравнивание</label>
<button class="align-btn" data-align="right"> <div class="alignment-buttons">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <button class="align-btn active" data-align="left">
<line x1="21" y1="10" x2="7" y2="10"></line> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="21" y1="6" x2="3" y2="6"></line> <line x1="17" y1="10" x2="3" y2="10"></line>
<line x1="21" y1="14" x2="3" y2="14"></line> <line x1="21" y1="6" x2="3" y2="6"></line>
<line x1="21" y1="18" x2="7" y2="18"></line> <line x1="21" y1="14" x2="3" y2="14"></line>
</svg> <line x1="17" y1="18" x2="3" y2="18"></line>
</button> </svg>
</div> </button>
</div> <button class="align-btn" data-align="center">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<div class="setting-row"> <line x1="18" y1="10" x2="6" y2="10"></line>
<label class="setting-label">Отступы</label> <line x1="21" y1="6" x2="3" y2="6"></line>
<div class="setting-control"> <line x1="21" y1="14" x2="3" y2="14"></line>
<input type="range" id="sidePadding" min="0" max="50" value="10" class="slider" /> <line x1="18" y1="18" x2="6" y2="18"></line>
<span class="value-display" id="sidePaddingValue">10</span> </svg>
</div> </button>
</div> <button class="align-btn" data-align="right">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<div class="setting-row"> <line x1="21" y1="10" x2="7" y2="10"></line>
<label class="setting-label">Смещение</label> <line x1="21" y1="6" x2="3" y2="6"></line>
<div class="setting-control"> <line x1="21" y1="14" x2="3" y2="14"></line>
<input type="range" id="centerOffset" min="-50" max="50" value="0" class="slider" /> <line x1="21" y1="18" x2="7" y2="18"></line>
<span class="value-display" id="centerOffsetValue">0</span> </svg>
</div> </button>
</div> </div>
</div> </div>
</section>
</div> <div class="setting-row">
<label class="setting-label">Отступы</label>
<!-- Right Column --> <div class="setting-control">
<div class="right-column"> <input type="range" id="sidePadding" min="0" max="50" value="10" class="slider" />
<section class="card"> <span class="value-display" id="sidePaddingValue">10</span>
<h2 class="card-title">Фоновое изображение</h2> </div>
</div>
<div class="crop-editor">
<div class="crop-canvas-container" id="cropCanvasContainer"> <div class="setting-row">
<canvas id="cropCanvas" class="crop-canvas"></canvas> <label class="setting-label">Смещение</label>
<div class="crop-box" id="cropBox"> <div class="setting-control">
<div class="crop-handle nw"></div> <input
<div class="crop-handle ne"></div> type="range"
<div class="crop-handle sw"></div> id="centerOffset"
<div class="crop-handle se"></div> min="-50"
<div class="crop-handle n"></div> max="50"
<div class="crop-handle s"></div> value="0"
<div class="crop-handle w"></div> class="slider"
<div class="crop-handle e"></div> />
</div> <span class="value-display" id="centerOffsetValue">0</span>
</div> </div>
</div>
<div class="crop-controls"> </div>
<button id="uploadBgBtn" class="btn btn-secondary"> </section>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> </div>
<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> <!-- Right Column -->
<line x1="12" y1="3" x2="12" y2="15"></line> <div class="right-column">
</svg> <section class="card">
Загрузить <h2 class="card-title">Фоновое изображение</h2>
</button>
<input type="file" id="bgImageInput" accept="image/*" style="display: none" /> <div class="crop-editor">
<div class="crop-canvas-container" id="cropCanvasContainer">
<button id="resetCropBtn" class="btn btn-outline"> <canvas id="cropCanvas" class="crop-canvas"></canvas>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <div class="crop-box" id="cropBox">
<polyline points="1 4 1 10 7 10"></polyline> <div class="crop-handle nw"></div>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path> <div class="crop-handle ne"></div>
</svg> <div class="crop-handle sw"></div>
Сбросить <div class="crop-handle se"></div>
</button> <div class="crop-handle n"></div>
</div> <div class="crop-handle s"></div>
<div class="crop-handle w"></div>
<div class="settings-grid"> <div class="crop-handle e"></div>
<div class="setting-row"> </div>
<label class="setting-label">Яркость</label> </div>
<div class="setting-control">
<input type="range" id="bgBrightness" min="50" max="150" value="100" class="slider" /> <div class="crop-controls">
<span class="value-display" id="bgBrightnessValue">100</span> <button id="uploadBgBtn" class="btn btn-secondary">
</div> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
</div> <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>
<div class="setting-row"> <line x1="12" y1="3" x2="12" y2="15"></line>
<label class="setting-label">Контраст</label> </svg>
<div class="setting-control"> Загрузить
<input type="range" id="bgContrast" min="50" max="150" value="100" class="slider" /> </button>
<span class="value-display" id="bgContrastValue">100</span> <input type="file" id="bgImageInput" accept="image/*" style="display: none" />
</div>
</div> <button id="resetCropBtn" class="btn btn-outline">
</div> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
</div> <polyline points="1 4 1 10 7 10"></polyline>
</section> <path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
</svg>
<section class="card"> Сбросить
<div class="card-header-row"> </button>
<h2 class="card-title">Панели <span class="badge" id="panelCount">0</span></h2> </div>
<div class="panel-nav">
<button id="prevPanel" class="nav-btn" disabled> <div class="settings-grid">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <div class="setting-row">
<polyline points="15 18 9 12 15 6"></polyline> <label class="setting-label">Яркость</label>
</svg> <div class="setting-control">
</button> <input
<span id="panelIndicator" class="panel-indicator">0 / 0</span> type="range"
<button id="nextPanel" class="nav-btn" disabled> id="bgBrightness"
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> min="50"
<polyline points="9 18 15 12 9 6"></polyline> max="150"
</svg> value="100"
</button> class="slider"
</div> />
</div> <span class="value-display" id="bgBrightnessValue">100</span>
</div>
<div class="panel-viewer"> </div>
<div id="panelDisplay" class="panel-display">
<div class="empty-state"> <div class="setting-row">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <label class="setting-label">Контраст</label>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect> <div class="setting-control">
<line x1="9" y1="9" x2="15" y2="9"></line> <input
</svg> type="range"
<p>Добавьте тексты для создания панелей</p> id="bgContrast"
</div> min="50"
</div> max="150"
value="100"
<div class="panel-actions"> class="slider"
<button id="downloadCurrentBtn" class="btn btn-primary" disabled> />
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <span class="value-display" id="bgContrastValue">100</span>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path> </div>
<polyline points="7 10 12 15 17 10"></polyline> </div>
<line x1="12" y1="15" x2="12" y2="3"></line> </div>
</svg> </div>
Скачать </section>
</button>
<button id="downloadAllBtn" class="btn btn-outline" disabled> <section class="card">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <div class="card-header-row">
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"></path> <h2 class="card-title">Панели <span class="badge" id="panelCount">0</span></h2>
<polyline points="7 11 12 16 17 11"></polyline> <div class="panel-nav">
<line x1="12" y1="16" x2="12" y2="4"></line> <button id="prevPanel" class="nav-btn" disabled>
</svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
Скачать все <polyline points="15 18 9 12 15 6"></polyline>
</button> </svg>
</div> </button>
</div> <span id="panelIndicator" class="panel-indicator">0 / 0</span>
</section> <button id="nextPanel" class="nav-btn" disabled>
</div> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
</div> <polyline points="9 18 15 12 9 6"></polyline>
</div> </svg>
</button>
<script src="script.js"></script> </div>
</body> </div>
</html>
<div class="panel-viewer">
<div id="panelDisplay" class="panel-display">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="9" y1="9" x2="15" y2="9"></line>
</svg>
<p>Добавьте тексты для создания панелей</p>
</div>
</div>
<div class="panel-actions">
<button id="downloadCurrentBtn" class="btn btn-primary" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
Скачать
</button>
<button id="downloadAllBtn" class="btn btn-outline" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"></path>
<polyline points="7 11 12 16 17 11"></polyline>
<line x1="12" y1="16" x2="12" y2="4"></line>
</svg>
Скачать все
</button>
</div>
</div>
</section>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>
+625 -570
View File
File diff suppressed because it is too large Load Diff
+763 -763
View File
File diff suppressed because it is too large Load Diff
+61 -61
View File
@@ -1,61 +1,61 @@
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import { globSync } from "glob"; import { globSync } from "glob";
const includePatterns = ["src/**/*.svelte", "src/**/*.css"]; const includePatterns = ["src/**/*.svelte", "src/**/*.css"];
const files = globSync(includePatterns); const files = globSync(includePatterns);
const defined = new Set(); const defined = new Set();
const used = new Set(); const used = new Set();
const usageLocations = []; const usageLocations = [];
files.forEach((file) => { files.forEach((file) => {
const content = readFileSync(file, "utf-8"); const content = readFileSync(file, "utf-8");
const lines = content.split("\n"); const lines = content.split("\n");
const defMatches = content.matchAll(/--[\w-]+(?=\s*:)/g); const defMatches = content.matchAll(/--[\w-]+(?=\s*:)/g);
for (const m of defMatches) { for (const m of defMatches) {
defined.add(m[0]); defined.add(m[0]);
} }
lines.forEach((line, idx) => { lines.forEach((line, idx) => {
const varMatches = line.matchAll(/var\(\s*--[\w-]+\s*\)/g); const varMatches = line.matchAll(/var\(\s*--[\w-]+\s*\)/g);
for (const match of varMatches) { for (const match of varMatches) {
const varName = match[0].slice(4, -1).trim(); const varName = match[0].slice(4, -1).trim();
used.add(varName); used.add(varName);
usageLocations.push({ usageLocations.push({
file, file,
line: idx + 1, line: idx + 1,
column: match.index + 1, column: match.index + 1,
varName, varName,
}); });
} }
}); });
}); });
let hasUndefined = false; let hasUndefined = false;
const undefinedErrors = usageLocations.filter((loc) => !defined.has(loc.varName)); const undefinedErrors = usageLocations.filter((loc) => !defined.has(loc.varName));
undefinedErrors.forEach(({ file, line, column, varName }) => { undefinedErrors.forEach(({ file, line, column, varName }) => {
console.error(`${file}:${line}:${column} — не определена CSS-переменная "${varName}"`); console.error(`${file}:${line}:${column} — не определена CSS-переменная "${varName}"`);
hasUndefined = true; hasUndefined = true;
}); });
const unused = [...defined].filter((varName) => !used.has(varName)); const unused = [...defined].filter((varName) => !used.has(varName));
if (unused.length > 0) { if (unused.length > 0) {
console.warn("\n⚠️ Объявлены, но нигде не используются:"); console.warn("\n⚠️ Объявлены, но нигде не используются:");
unused.forEach((varName) => console.warn(` ${varName}`)); unused.forEach((varName) => console.warn(` ${varName}`));
} }
if (hasUndefined) { if (hasUndefined) {
console.error("\n❌ Найдены неопределённые переменные. Исправьте их."); console.error("\n❌ Найдены неопределённые переменные. Исправьте их.");
process.exit(1); process.exit(1);
} else { } else {
if (unused.length === 0) { if (unused.length === 0) {
console.log("\n✅ Все CSS-переменные определены и используются."); console.log("\n✅ Все CSS-переменные определены и используются.");
} else { } else {
console.log("\n✅ Неопределённых переменных нет, но есть неиспользуемые (см. предупреждения)."); console.log("\n✅ Неопределённых переменных нет, но есть неиспользуемые (см. предупреждения).");
} }
process.exit(0); process.exit(0);
} }
+79 -79
View File
@@ -1,79 +1,79 @@
:root { :root {
--brand-main: #86efac; --brand-main: #86efac;
--brand-alt: oklch(from var(--brand-main) l c calc(h + 36)); --brand-alt: oklch(from var(--brand-main) l c calc(h + 36));
--action-lightness: 0.6; --action-lightness: 0.6;
--action-chroma: 0.18; --action-chroma: 0.18;
--hover-step: -0.08; --hover-step: -0.08;
--danger-base: #ef4444; --danger-base: #ef4444;
--surface-main: oklch(from var(--brand-main) 0.99 0.02 h); --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-subtle: oklch(from var(--brand-main) 0.95 0.1 h);
--surface-elevated: oklch(from var(--brand-main) 0.99 0.05 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-control: oklch(from var(--brand-main) 0.85 0.04 h);
--surface-hover: oklch(from var(--brand-main) 0.9 0.1 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: 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-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: 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); --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-main: oklch(from var(--brand-main) 0.25 0.02 h);
--text-muted: oklch(from var(--brand-main) 0.45 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); --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-main: oklch(from var(--brand-main) 0.9 0.04 h);
--border-strong: oklch(from var(--brand-main) 0.8 0.06 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: 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); --shadow-md: 0 4px 12px oklch(from var(--brand-main) 0.2 0.05 h / 0.1);
--radius: 8px; --radius: 8px;
--transition: all 0.3s ease; --transition: all 0.3s ease;
} }
[data-theme="dark"] { [data-theme="dark"] {
--action-lightness: 0.5; --action-lightness: 0.5;
--action-chroma: 0.18; --action-chroma: 0.18;
--surface-main: oklch(from var(--brand-main) 0.12 0.02 h); --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-subtle: oklch(from var(--brand-main) 0.16 0.03 h);
--surface-elevated: oklch(from var(--brand-main) 0.2 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-control: oklch(from var(--brand-main) 0.4 0.06 h);
--surface-hover: oklch(from var(--brand-main) 0.24 0.04 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-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); --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-main: oklch(from var(--brand-main) 0.94 0.01 h);
--text-muted: oklch(from var(--brand-main) 0.75 0.02 h); --text-muted: oklch(from var(--brand-main) 0.75 0.02 h);
--text-action: oklch(from var(--brand-main) 0.12 0.02 h); /* --text-action: oklch(from var(--brand-main) 0.12 0.02 h); */
--border-main: oklch(from var(--brand-main) 0.22 0.04 h); --border-main: oklch(from var(--brand-main) 0.22 0.04 h);
--border-strong: oklch(from var(--brand-main) 0.3 0.06 h); --border-strong: oklch(from var(--brand-main) 0.3 0.06 h);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5); --shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5); --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
} }
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
ul { ul {
list-style: none; list-style: none;
text-indent: 0; text-indent: 0;
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif;
background: var(--surface-main); background: var(--surface-main);
color: var(--text-main); color: var(--text-main);
min-height: 100vh; min-height: 100vh;
padding: 16px; padding: 16px;
transition: var(--transition); transition: var(--transition);
line-height: 1.5; line-height: 1.5;
} }
+108 -108
View File
@@ -1,108 +1,108 @@
<script lang="ts"> <script lang="ts">
let active = $state(false); let active = $state(false);
</script> </script>
<div class="crop-canvas-container"> <div class="crop-canvas-container">
<canvas class="crop-canvas"></canvas> <canvas class="crop-canvas"></canvas>
<div class="crop-box" class:active> <div class="crop-box" class:active>
<div class="crop-handle nw"></div> <div class="crop-handle nw"></div>
<div class="crop-handle ne"></div> <div class="crop-handle ne"></div>
<div class="crop-handle sw"></div> <div class="crop-handle sw"></div>
<div class="crop-handle se"></div> <div class="crop-handle se"></div>
<div class="crop-handle n"></div> <div class="crop-handle n"></div>
<div class="crop-handle s"></div> <div class="crop-handle s"></div>
<div class="crop-handle w"></div> <div class="crop-handle w"></div>
<div class="crop-handle e"></div> <div class="crop-handle e"></div>
</div> </div>
</div> </div>
<style> <style>
.crop-canvas-container { .crop-canvas-container {
position: relative; position: relative;
width: 100%; width: 100%;
aspect-ratio: 16 / 5; aspect-ratio: 16 / 5;
background: var(--surface-subtle); background: var(--surface-subtle);
border-radius: var(--radius); border-radius: var(--radius);
overflow: hidden; overflow: hidden;
cursor: crosshair; cursor: crosshair;
} }
.crop-canvas { .crop-canvas {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block; display: block;
} }
.crop-box { .crop-box {
position: absolute; position: absolute;
border: 2px solid var(--action-primary); border: 2px solid var(--action-primary);
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5); box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
cursor: move; cursor: move;
display: none; display: none;
} }
.crop-box.active { .crop-box.active {
display: block; display: block;
} }
.crop-handle { .crop-handle {
position: absolute; position: absolute;
width: 10px; width: 10px;
height: 10px; height: 10px;
background: white; background: white;
border: 2px solid var(--action-primary); border: 2px solid var(--action-primary);
border-radius: 50%; border-radius: 50%;
} }
.crop-handle.nw { .crop-handle.nw {
top: -5px; top: -5px;
left: -5px; left: -5px;
cursor: nw-resize; cursor: nw-resize;
} }
.crop-handle.ne { .crop-handle.ne {
top: -5px; top: -5px;
right: -5px; right: -5px;
cursor: ne-resize; cursor: ne-resize;
} }
.crop-handle.sw { .crop-handle.sw {
bottom: -5px; bottom: -5px;
left: -5px; left: -5px;
cursor: sw-resize; cursor: sw-resize;
} }
.crop-handle.se { .crop-handle.se {
bottom: -5px; bottom: -5px;
right: -5px; right: -5px;
cursor: se-resize; cursor: se-resize;
} }
.crop-handle.n { .crop-handle.n {
top: -5px; top: -5px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
cursor: n-resize; cursor: n-resize;
} }
.crop-handle.s { .crop-handle.s {
bottom: -5px; bottom: -5px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
cursor: s-resize; cursor: s-resize;
} }
.crop-handle.w { .crop-handle.w {
left: -5px; left: -5px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
cursor: w-resize; cursor: w-resize;
} }
.crop-handle.e { .crop-handle.e {
right: -5px; right: -5px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
cursor: e-resize; cursor: e-resize;
} }
</style> </style>
+52 -52
View File
@@ -1,52 +1,52 @@
<script lang="ts"> <script lang="ts">
import Card from "$components/layout/Card.svelte"; import Card from "$components/layout/Card.svelte";
import SettingsGrid from "$components/layout/SettingsGrid.svelte"; import SettingsGrid from "$components/layout/SettingsGrid.svelte";
import SettingsRow from "$components/layout/SettingsRow.svelte"; import SettingsRow from "$components/layout/SettingsRow.svelte";
import Button from "$components/ui/Button.svelte"; import Button from "$components/ui/Button.svelte";
import RangeSlider from "$components/ui/RangeSlider.svelte"; import RangeSlider from "$components/ui/RangeSlider.svelte";
// import { Pencil, RotateCcw as Reset, Upload } from "@lucide/svelte"; // import { Pencil, RotateCcw as Reset, Upload } from "@lucide/svelte";
// import Pencil from "@lucide/svelte/icons/pencil"; // import Pencil from "@lucide/svelte/icons/pencil";
// import Reset from "@lucide/svelte/icons/rotate-ccw"; // import Reset from "@lucide/svelte/icons/rotate-ccw";
// import Upload from "@lucide/svelte/icons/upload"; // import Upload from "@lucide/svelte/icons/upload";
import Pencil from "~icons/lucide/pencil"; import Pencil from "~icons/lucide/pencil";
import Reset from "~icons/lucide/rotate-ccw"; import Reset from "~icons/lucide/rotate-ccw";
import Upload from "~icons/lucide/upload"; import Upload from "~icons/lucide/upload";
import CropInline from "./CropInline.svelte"; import CropInline from "./CropInline.svelte";
let brightness = $state(100); let brightness = $state(100);
let contrast = $state(100); let contrast = $state(100);
</script> </script>
<Card title="Фоновое изображение"> <Card title="Фоновое изображение">
<div class="crop-editor"> <div class="crop-editor">
<CropInline /> <CropInline />
<div class="crop-controls"> <div class="crop-controls">
<Button label="Загрузить" ariaLabel="Upload" icon={Upload} type="secondary" /> <Button label="Загрузить" ariaLabel="Upload" icon={Upload} type="secondary" />
<Button label="Редактировать" ariaLabel="Edit" icon={Reset} type="outline" /> <Button label="Редактировать" ariaLabel="Edit" icon={Reset} type="outline" />
<Button label="Сбросить" ariaLabel="Reset" icon={Pencil} type="outline" /> <Button label="Сбросить" ariaLabel="Reset" icon={Pencil} type="outline" />
</div> </div>
<SettingsGrid> <SettingsGrid>
<SettingsRow label="Яркость"> <SettingsRow label="Яркость">
<RangeSlider bind:value={brightness} /> <RangeSlider bind:value={brightness} />
</SettingsRow> </SettingsRow>
<SettingsRow label="Контраст"> <SettingsRow label="Контраст">
<RangeSlider bind:value={contrast} min={50} max={150} /> <RangeSlider bind:value={contrast} min={50} max={150} />
</SettingsRow> </SettingsRow>
</SettingsGrid> </SettingsGrid>
</div> </div>
</Card> </Card>
<style> <style>
.crop-editor { .crop-editor {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
} }
.crop-controls { .crop-controls {
display: flex; display: flex;
gap: 8px; gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
} }
</style> </style>
+86 -86
View File
@@ -1,86 +1,86 @@
<script lang="ts"> <script lang="ts">
import { themeState } from "$states/theme.svelte"; import { themeState } from "$states/theme.svelte";
import Moon from "~icons/lucide/moon"; import Moon from "~icons/lucide/moon";
import Sun from "~icons/lucide/sun"; import Sun from "~icons/lucide/sun";
function toggleTheme() { function toggleTheme() {
themeState.toggle(); themeState.toggle();
} }
</script> </script>
<header class="header"> <header class="header">
<div class="header-content"> <div class="header-content">
<h1>Twitch Panels</h1> <h1>Twitch Panels</h1>
<button class="theme-toggle" aria-label="Toggle theme" onclick={toggleTheme}> <button class="theme-toggle" aria-label="Toggle theme" onclick={toggleTheme}>
<Sun class="sun-icon" /> <Sun class="sun-icon" />
<Moon class="moon-icon" /> <Moon class="moon-icon" />
</button> </button>
</div> </div>
</header> </header>
<style> <style>
.header { .header {
background: linear-gradient(135deg, var(--action-primary) 0%, var(--action-secondary) 100%); background: linear-gradient(135deg, var(--action-primary) 0%, var(--action-secondary) 100%);
color: var(--text-action); color: var(--text-action);
padding: 16px 20px; padding: 16px 20px;
border-radius: var(--radius); border-radius: var(--radius);
margin-bottom: 16px; margin-bottom: 16px;
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
} }
.header-content { .header-content {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.header h1 { .header h1 {
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
} }
.theme-toggle { .theme-toggle {
width: 36px; width: 36px;
height: 36px; height: 36px;
border-radius: 6px; border-radius: 6px;
border: none; border: none;
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: var(--transition); transition: var(--transition);
position: relative; position: relative;
} }
.theme-toggle:hover { .theme-toggle:hover {
background: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.3);
} }
:global(.theme-toggle svg) { :global(.theme-toggle svg) {
width: 18px; width: 18px;
height: 18px; height: 18px;
position: absolute; position: absolute;
transition: var(--transition); transition: var(--transition);
} }
:global(.sun-icon) { :global(.sun-icon) {
opacity: 1; opacity: 1;
transform: rotate(0deg); transform: rotate(0deg);
} }
:global(.moon-icon) { :global(.moon-icon) {
opacity: 0; opacity: 0;
transform: rotate(90deg); transform: rotate(90deg);
} }
:global([data-theme="dark"] .sun-icon) { :global([data-theme="dark"] .sun-icon) {
opacity: 0; opacity: 0;
transform: rotate(90deg); transform: rotate(90deg);
} }
:global([data-theme="dark"] .moon-icon) { :global([data-theme="dark"] .moon-icon) {
opacity: 1; opacity: 1;
transform: rotate(0deg); transform: rotate(0deg);
} }
</style> </style>
+67 -68
View File
@@ -1,68 +1,67 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
interface Props { interface Props {
title: string | Snippet; title: string | Snippet;
children: Snippet; children: Snippet;
titleSnippet?: Snippet; titleSnippet?: Snippet;
} }
let { title, children, titleSnippet = emptySnippet }: Props = $props(); let { title, children, titleSnippet = emptySnippet }: Props = $props();
</script> </script>
<!-- svelte-ignore block_empty --> {#snippet emptySnippet()}{/snippet}
{#snippet emptySnippet()}{/snippet}
<section class="card">
<section class="card"> <div class="card-header-row">
<div class="card-header-row"> <h2 class="card-title">
<h2 class="card-title"> {#if typeof title === "string"}
{#if typeof title === "string"} {title}
{title} {:else}
{:else} {@render title()}
{@render title()} {/if}
{/if} </h2>
</h2> <div class="card-snippet">
<div class="card-snippet"> {@render titleSnippet()}
{@render titleSnippet()} </div>
</div> </div>
</div> <div class="card-body">
<div class="card-body"> {@render children()}
{@render children()} </div>
</div> </section>
</section>
<style>
<style> .card {
.card { background: var(--surface-elevated);
background: var(--surface-elevated); border: 1px solid var(--border-main);
border: 1px solid var(--border-main); border-radius: var(--radius);
border-radius: var(--radius); padding: 16px;
padding: 16px; margin-bottom: 16px;
margin-bottom: 16px; transition: var(--transition);
transition: var(--transition); overflow: hidden;
overflow: hidden; box-shadow: var(--shadow);
box-shadow: var(--shadow); }
}
.card-title {
.card-title { font-size: 15px;
font-size: 15px; font-weight: 600;
font-weight: 600; color: var(--text-main);
color: var(--text-main); margin-bottom: 12px;
margin-bottom: 12px; display: flex;
display: flex; align-items: center;
align-items: center; gap: 8px;
gap: 8px; }
}
.card-header-row {
.card-header-row { display: flex;
display: flex; justify-content: space-between;
justify-content: space-between; align-items: center;
align-items: center; margin-bottom: 12px;
margin-bottom: 12px; }
}
.card-snippet {
.card-snippet { display: flex;
display: flex; align-items: center;
align-items: center; gap: 8px;
gap: 8px; }
} </style>
</style>
+21 -21
View File
@@ -1,21 +1,21 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
interface Props { interface Props {
children: Snippet; children: Snippet;
} }
let { children }: Props = $props(); let { children }: Props = $props();
</script> </script>
<div class="input-group"> <div class="input-group">
{@render children()} {@render children()}
</div> </div>
<style> <style>
.input-group { .input-group {
display: flex; display: flex;
gap: 8px; gap: 8px;
margin-bottom: 12px; margin-bottom: 12px;
} }
</style> </style>
+9 -9
View File
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import ImageManager from "$components/image/ImageManager.svelte"; import ImageManager from "$components/image/ImageManager.svelte";
import PreviewManager from "$components/panel/PreviewManager.svelte"; import PreviewManager from "$components/panel/PreviewManager.svelte";
</script> </script>
<div class="panel-bar"> <div class="panel-bar">
<ImageManager /> <ImageManager />
<PreviewManager /> <PreviewManager />
</div> </div>
+21 -21
View File
@@ -1,21 +1,21 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
interface Props { interface Props {
children: Snippet; children: Snippet;
} }
let { children }: Props = $props(); let { children }: Props = $props();
</script> </script>
<div class="settings-grid"> <div class="settings-grid">
{@render children()} {@render children()}
</div> </div>
<style> <style>
.settings-grid { .settings-grid {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
} }
</style> </style>
+41 -41
View File
@@ -1,41 +1,41 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
interface Props { interface Props {
label: string; label: string;
children: Snippet; children: Snippet;
noLabel?: boolean; noLabel?: boolean;
} }
let { label, children, noLabel = false }: Props = $props(); let { label, children, noLabel = false }: Props = $props();
let tag = $derived(noLabel ? "div" : "label"); let tag = $derived(noLabel ? "div" : "label");
</script> </script>
<svelte:element this={tag} class="setting-row"> <svelte:element this={tag} class="setting-row">
<div class="setting-label">{label}</div> <div class="setting-label">{label}</div>
<div class="setting-control"> <div class="setting-control">
{@render children()} {@render children()}
</div> </div>
</svelte:element> </svelte:element>
<style> <style>
.setting-row { .setting-row {
display: grid; display: grid;
grid-template-columns: 100px 1fr; grid-template-columns: 100px 1fr;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
} }
.setting-label { .setting-label {
color: var(--text-muted); color: var(--text-muted);
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
} }
.setting-control { .setting-control {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
} }
</style> </style>
+9 -9
View File
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import TextConfig from "$components/text/TextConfig.svelte"; import TextConfig from "$components/text/TextConfig.svelte";
import TextManager from "$components/text/TextManager.svelte"; import TextManager from "$components/text/TextManager.svelte";
</script> </script>
<div class="text-bar"> <div class="text-bar">
<TextManager /> <TextManager />
<TextConfig /> <TextConfig />
</div> </div>
+37 -37
View File
@@ -1,37 +1,37 @@
<script lang="ts"> <script lang="ts">
import { PANEL_SETTINGS } from "$lib/constants"; import { PANEL_SETTINGS } from "$lib/constants";
import { imageConfigState } from "$states/imageConfig.svelte"; import { imageConfigState } from "$states/imageConfig.svelte";
import { textConfigState } from "$states/textConfig.svelte"; import { textConfigState } from "$states/textConfig.svelte";
import { Image, Layer, Stage, Text } from "svelte-konva"; import { Image, Layer, Stage, Text } from "svelte-konva";
interface Props { interface Props {
text: string; text: string;
stage: Stage | undefined; stage: Stage | undefined;
} }
let { text, stage = $bindable() }: Props = $props(); let { text, stage = $bindable() }: Props = $props();
let x = $derived(textConfigState.paddingX); let x = $derived(textConfigState.paddingX);
let y = $derived(10 + textConfigState.offsetY); let y = $derived(10 + textConfigState.offsetY);
let width = $derived(PANEL_SETTINGS.PANEL_WIDTH - 2 * textConfigState.paddingX); let width = $derived(PANEL_SETTINGS.PANEL_WIDTH - 2 * textConfigState.paddingX);
let height = PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT; let height = PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT;
</script> </script>
<Stage width={320} height={100} bind:this={stage}> <Stage width={320} height={100} bind:this={stage}>
<Layer> <Layer>
<Image image={imageConfigState.image}></Image> <Image image={imageConfigState.image}></Image>
<Text <Text
{text} {text}
{x} {x}
{y} {y}
{width} {width}
{height} {height}
fontSize={textConfigState.fontSize} fontSize={textConfigState.fontSize}
fill={textConfigState.color} fill={textConfigState.color}
fontFamily={textConfigState.fontFamily} fontFamily={textConfigState.fontFamily}
align={textConfigState.align} align={textConfigState.align}
wrap="none" wrap="none"
ellipsis={true} ellipsis={true}
/> />
</Layer> </Layer>
</Stage> </Stage>
+22 -22
View File
@@ -1,22 +1,22 @@
<script lang="ts"> <script lang="ts">
import { konvaAllStagesState } from "$states/konvaAllStages.svelte"; import { konvaAllStagesState } from "$states/konvaAllStages.svelte";
import { textsState } from "$states/texts.svelte"; import { textsState } from "$states/texts.svelte";
import Preview from "./Preview.svelte"; import Preview from "./Preview.svelte";
</script> </script>
<div class="outside-display"> <div class="outside-display">
{#each textsState.texts as { text, id }, idx (id)} {#each textsState.texts as { text, id }, idx (id)}
<Preview {text} bind:stage={konvaAllStagesState[idx]} /> <Preview {text} bind:stage={konvaAllStagesState[idx]} />
{:else} {:else}
<p>No texts to preview</p> <p>No texts to preview</p>
{/each} {/each}
</div> </div>
<style> <style>
.outside-display { .outside-display {
position: fixed; position: fixed;
top: -1000px; top: -1000px;
left: -1000px; left: -1000px;
} }
</style> </style>
+46 -40
View File
@@ -1,40 +1,46 @@
<script lang="ts"> <script lang="ts">
import Button from "$components/ui/Button.svelte"; import Button from "$components/ui/Button.svelte";
import { SlideDirection, type SlideDirectionType } from "$lib/constants"; import { SlideDirection, type SlideDirectionType } from "$lib/constants";
import ChevronLeft from "~icons/lucide/chevron-left"; import ChevronLeft from "~icons/lucide/chevron-left";
import ChevronRight from "~icons/lucide/chevron-right"; import ChevronRight from "~icons/lucide/chevron-right";
interface Props { interface Props {
current: number; current: number;
direction: SlideDirectionType; direction: SlideDirectionType;
max: number; max: number;
} }
let { current = $bindable(), direction = $bindable(SlideDirection.NEXT), max }: Props = $props(); let { current = $bindable(), direction = $bindable(SlideDirection.NEXT), max }: Props = $props();
let isFirst = $derived(current === 0); let isFirst = $derived(current === 0);
let isLast = $derived(current === max - 1); let isLast = $derived(current === max - 1);
function prev() { function prev() {
if (current > 0) current--; if (current > 0) current--;
direction = SlideDirection.PREV; direction = SlideDirection.PREV;
} }
async function next() { async function next() {
if (current < max - 1) current++; if (current < max - 1) current++;
direction = SlideDirection.NEXT; direction = SlideDirection.NEXT;
} }
</script> </script>
<Button icon={ChevronLeft} ariaLabel="Previous slide" type="mini" onclick={prev} disabled={isFirst} /> <Button
<span class="panel-indicator">{current + 1} / {max}</span> icon={ChevronLeft}
<Button icon={ChevronRight} ariaLabel="Next slide" type="mini" onclick={next} disabled={isLast} /> ariaLabel="Previous slide"
type="mini"
<style> onclick={prev}
.panel-indicator { disabled={isFirst}
font-size: 13px; />
font-weight: 500; <span class="panel-indicator">{current + 1} / {max}</span>
min-width: 50px; <Button icon={ChevronRight} ariaLabel="Next slide" type="mini" onclick={next} disabled={isLast} />
text-align: center;
} <style>
</style> .panel-indicator {
font-size: 13px;
font-weight: 500;
min-width: 50px;
text-align: center;
}
</style>
+144 -136
View File
@@ -1,136 +1,144 @@
<script lang="ts"> <script lang="ts">
import Card from "$components/layout/Card.svelte"; import Card from "$components/layout/Card.svelte";
import Badge from "$components/ui/Badge.svelte"; import Badge from "$components/ui/Badge.svelte";
import Button from "$components/ui/Button.svelte"; import Button from "$components/ui/Button.svelte";
import { PANEL_SETTINGS, TRANSITION_DURATION, type SlideDirectionType } from "$lib/constants"; import { PANEL_SETTINGS, TRANSITION_DURATION, type SlideDirectionType } from "$lib/constants";
import { downloadService, type DownloadItem } from "$services/downloadService"; import { downloadService, type DownloadItem } from "$services/downloadService";
import { konvaAllStagesState } from "$states/konvaAllStages.svelte"; import { konvaAllStagesState } from "$states/konvaAllStages.svelte";
import { konvaStageState } from "$states/konvaStage.svelte"; import { konvaStageState } from "$states/konvaStage.svelte";
import { textsState } from "$states/texts.svelte"; import { textsState } from "$states/texts.svelte";
import { fly } from "svelte/transition"; import { fly } from "svelte/transition";
import Download from "~icons/lucide/download"; import Download from "~icons/lucide/download";
import StickyNote from "~icons/lucide/sticky-note"; import StickyNote from "~icons/lucide/sticky-note";
import Preview from "./Preview.svelte"; import Preview from "./Preview.svelte";
import PreviewAll from "./PreviewAll.svelte"; import PreviewAll from "./PreviewAll.svelte";
import PreviewControls from "./PreviewControls.svelte"; import PreviewControls from "./PreviewControls.svelte";
let current: number = $state(0); let current: number = $state(0);
let direction: SlideDirectionType = $state("next"); let direction: SlideDirectionType = $state("next");
let xDirection = $derived(direction == "next" ? PANEL_SETTINGS.PANEL_WIDTH : -PANEL_SETTINGS.PANEL_WIDTH); let xDirection = $derived(
direction == "next" ? PANEL_SETTINGS.PANEL_WIDTH : -PANEL_SETTINGS.PANEL_WIDTH,
$effect(() => { );
if (textsState.texts.length === 0) {
current = 0; $effect(() => {
} else { if (textsState.texts.length === 0) {
if (current > textsState.texts.length - 1) { current = 0;
current = textsState.texts.length - 1; } 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, function downloadAll() {
stage: konvaAllStagesState[idx]?.node, let downloadItems: Array<DownloadItem> = textsState.texts.map((text, idx) => ({
})); filename: text.text,
stage: konvaAllStagesState[idx]?.node,
downloadService.downloadAll(downloadItems); }));
}
downloadService.downloadAll(downloadItems);
function downloadCurrent() { }
let node = konvaStageState.stage?.node;
if (node) { function downloadCurrent() {
downloadService.downloadPanel(node, textsState.texts[current].text); let node = konvaStageState.stage?.node;
} if (node) {
} downloadService.downloadPanel(node, textsState.texts[current].text);
</script> }
}
{#snippet panelTitle()} </script>
Панели <Badge text={textsState.texts.length} />
{/snippet} {#snippet panelTitle()}
Панели <Badge text={textsState.texts.length} />
{#snippet panelControls()} {/snippet}
<PreviewControls bind:current bind:direction max={textsState.texts.length} />
{/snippet} {#snippet panelControls()}
<PreviewControls bind:current bind:direction max={textsState.texts.length} />
<Card title={panelTitle} titleSnippet={panelControls}> {/snippet}
<div class="panel-viewer">
<div class="panel-display"> <Card title={panelTitle} titleSnippet={panelControls}>
{#if textsState.texts.length} <div class="panel-viewer">
{#key current} <div class="panel-display">
{@const text = textsState?.texts[current]?.text} {#if textsState.texts.length}
<div {#key current}
class="konva-wrapper" {@const text = textsState?.texts[current]?.text}
in:fly={{ x: xDirection, duration: TRANSITION_DURATION }} <div
out:fly={{ x: -xDirection, duration: TRANSITION_DURATION }} class="konva-wrapper"
> in:fly={{ x: xDirection, duration: TRANSITION_DURATION }}
<Preview {text} bind:stage={konvaStageState.stage} /> out:fly={{ x: -xDirection, duration: TRANSITION_DURATION }}
</div> >
{/key} <Preview {text} bind:stage={konvaStageState.stage} />
{:else} </div>
<div class="empty-state" aria-label="Empty texts info"> {/key}
<StickyNote /> {:else}
<p>Добавьте тексты для создания панелей</p> <div class="empty-state" aria-label="Empty texts info">
</div> <StickyNote />
{/if} <p>Добавьте тексты для создания панелей</p>
</div> </div>
{/if}
<div class="panel-actions"> </div>
<Button label="Скачать всё" ariaLabel="Download all" icon={Download} onclick={downloadAll} />
<Button label="Скачать" ariaLabel="Download current" type="secondary" icon={Download} onclick={downloadCurrent} /> <div class="panel-actions">
</div> <Button label="Скачать всё" ariaLabel="Download all" icon={Download} onclick={downloadAll} />
</div> <Button
</Card> label="Скачать"
<PreviewAll /> ariaLabel="Download current"
type="secondary"
<style> icon={Download}
.panel-viewer { onclick={downloadCurrent}
display: flex; />
flex-direction: column; </div>
gap: 12px; </div>
} </Card>
<PreviewAll />
.panel-display {
background: var(--surface-subtle); <style>
border-radius: var(--radius); .panel-viewer {
padding: 20px; display: flex;
display: flex; flex-direction: column;
justify-content: center; gap: 12px;
align-items: center; }
min-height: 150px;
position: relative; .panel-display {
} background: var(--surface-subtle);
border-radius: var(--radius);
.panel-actions { padding: 20px;
display: flex; display: flex;
gap: 8px; justify-content: center;
} align-items: center;
min-height: 150px;
.empty-state { position: relative;
text-align: center; }
padding: 32px 16px;
} .panel-actions {
display: flex;
.empty-state :global(svg) { gap: 8px;
width: 48px; }
height: 48px;
margin: 0 auto 12px; .empty-state {
opacity: 0.5; text-align: center;
} padding: 32px 16px;
}
.empty-state p {
font-size: 14px; .empty-state :global(svg) {
} width: 48px;
height: 48px;
.konva-wrapper { margin: 0 auto 12px;
position: absolute; opacity: 0.5;
top: 0; }
left: 0;
width: 100%; .empty-state p {
height: 100%; font-size: 14px;
display: flex; }
justify-content: center;
align-items: center; .konva-wrapper {
} position: absolute;
</style> top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
</style>
+33 -33
View File
@@ -1,33 +1,33 @@
<script lang="ts"> <script lang="ts">
import Card from "$components/layout/Card.svelte"; import Card from "$components/layout/Card.svelte";
import SettingsGrid from "$components/layout/SettingsGrid.svelte"; import SettingsGrid from "$components/layout/SettingsGrid.svelte";
import SettingsRow from "$components/layout/SettingsRow.svelte"; import SettingsRow from "$components/layout/SettingsRow.svelte";
import Alignment from "$components/ui/Alignment.svelte"; import Alignment from "$components/ui/Alignment.svelte";
import ColorPicker from "$components/ui/ColorPicker.svelte"; import ColorPicker from "$components/ui/ColorPicker.svelte";
import RangeSlider from "$components/ui/RangeSlider.svelte"; import RangeSlider from "$components/ui/RangeSlider.svelte";
import SelectFont from "$components/ui/SelectFont.svelte"; import SelectFont from "$components/ui/SelectFont.svelte";
import { textConfigState } from "$states/textConfig.svelte"; import { textConfigState } from "$states/textConfig.svelte";
</script> </script>
<Card title="Настройки текста"> <Card title="Настройки текста">
<SettingsGrid> <SettingsGrid>
<SettingsRow label="Размер"> <SettingsRow label="Размер">
<RangeSlider bind:value={textConfigState.fontSize} min={10} max={100} step={1} /> <RangeSlider bind:value={textConfigState.fontSize} min={10} max={100} step={1} />
</SettingsRow> </SettingsRow>
<SettingsRow label="Шрифт"> <SettingsRow label="Шрифт">
<SelectFont bind:value={textConfigState.fontFamily} /> <SelectFont bind:value={textConfigState.fontFamily} />
</SettingsRow> </SettingsRow>
<SettingsRow label="Цвет"> <SettingsRow label="Цвет">
<ColorPicker bind:value={textConfigState.color} /> <ColorPicker bind:value={textConfigState.color} />
</SettingsRow> </SettingsRow>
<SettingsRow label="Выравнивание" noLabel={true}> <SettingsRow label="Выравнивание" noLabel={true}>
<Alignment bind:align={textConfigState.align} /> <Alignment bind:align={textConfigState.align} />
</SettingsRow> </SettingsRow>
<SettingsRow label="Отступы"> <SettingsRow label="Отступы">
<RangeSlider bind:value={textConfigState.paddingX} min={0} max={100} step={1} /> <RangeSlider bind:value={textConfigState.paddingX} min={0} max={100} step={1} />
</SettingsRow> </SettingsRow>
<SettingsRow label="Смещение"> <SettingsRow label="Смещение">
<RangeSlider bind:value={textConfigState.offsetY} min={-100} max={100} step={1} /> <RangeSlider bind:value={textConfigState.offsetY} min={-100} max={100} step={1} />
</SettingsRow> </SettingsRow>
</SettingsGrid> </SettingsGrid>
</Card> </Card>
+53 -53
View File
@@ -1,53 +1,53 @@
<script lang="ts"> <script lang="ts">
import Button from "$components/ui/Button.svelte"; import Button from "$components/ui/Button.svelte";
import { TRANSITION_DURATION } from "$lib/constants"; import { TRANSITION_DURATION } from "$lib/constants";
import { fly } from "svelte/transition"; import { fly } from "svelte/transition";
import Cross from "~icons/lucide/x"; import Cross from "~icons/lucide/x";
interface Props { interface Props {
text: string; text: string;
id: number; id: number;
ondelete: (id: number) => void; ondelete: (id: number) => void;
} }
let { text = $bindable(), id, ondelete }: Props = $props(); let { text = $bindable(), id, ondelete }: Props = $props();
</script> </script>
<li class="text-item" transition:fly={{ x: 600, duration: TRANSITION_DURATION }}> <li class="text-item" transition:fly={{ x: 600, duration: TRANSITION_DURATION }}>
<input type="text" bind:value={text} /> <input type="text" bind:value={text} />
<Button icon={Cross} ariaLabel="Delete" type="danger" onclick={() => ondelete(id)} /> <Button icon={Cross} ariaLabel="Delete" type="danger" onclick={() => ondelete(id)} />
</li> </li>
<style> <style>
.text-item { .text-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 8px 10px; padding: 8px 10px;
background: var(--surface-subtle); background: var(--surface-subtle);
border: 1px solid var(--border-main); border: 1px solid var(--border-main);
border-radius: var(--radius); border-radius: var(--radius);
transition: var(--transition); transition: var(--transition);
} }
.text-item:hover { .text-item:hover {
border-color: var(--border-strong); border-color: var(--border-strong);
} }
.text-item input { .text-item input {
flex: 1; flex: 1;
padding: 6px 10px; padding: 6px 10px;
border: 1px solid var(--border-main); border: 1px solid var(--border-main);
border-radius: 4px; border-radius: 4px;
font-size: 14px; font-size: 14px;
background: var(--surface-main); background: var(--surface-main);
color: var(--text-main); color: var(--text-main);
transition: var(--transition); transition: var(--transition);
font-family: inherit; font-family: inherit;
} }
.text-item input:focus { .text-item input:focus {
outline: none; outline: none;
border-color: var(--border-strong); border-color: var(--border-strong);
} }
</style> </style>
+47 -47
View File
@@ -1,47 +1,47 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
text: string; text: string;
ariaLabel: string; ariaLabel: string;
onenter: () => void; onenter: () => void;
} }
let { text = $bindable(), onenter, ariaLabel }: Props = $props(); let { text = $bindable(), onenter, ariaLabel }: Props = $props();
function handleKeyboard(event: KeyboardEvent) { function handleKeyboard(event: KeyboardEvent) {
if (event.key === "Enter") { if (event.key === "Enter") {
onenter(); onenter();
} }
} }
</script> </script>
<input <input
aria-label={ariaLabel} aria-label={ariaLabel}
bind:value={text} bind:value={text}
class="text-input" class="text-input"
type="text" type="text"
placeholder="Введите текст..." placeholder="Введите текст..."
onkeydown={handleKeyboard} onkeydown={handleKeyboard}
/> />
<style> <style>
.text-input { .text-input {
flex: 1; flex: 1;
padding: 10px 12px; padding: 10px 12px;
border: 1px solid var(--border-main); border: 1px solid var(--border-main);
border-radius: var(--radius); border-radius: var(--radius);
font-size: 14px; font-size: 14px;
background: var(--surface-main); background: var(--surface-main);
color: var(--text-main); color: var(--text-main);
transition: var(--transition); transition: var(--transition);
font-family: inherit; font-family: inherit;
} }
.text-input:focus { .text-input:focus {
outline: none; outline: none;
border-color: var(--border-strong); border-color: var(--border-strong);
} }
.text-input::placeholder { .text-input::placeholder {
color: var(--text-muted); color: var(--text-muted);
} }
</style> </style>
+40 -40
View File
@@ -1,40 +1,40 @@
<script lang="ts"> <script lang="ts">
import Card from "$components/layout/Card.svelte"; import Card from "$components/layout/Card.svelte";
import InputGroup from "$components/layout/InputGroup.svelte"; import InputGroup from "$components/layout/InputGroup.svelte";
import Button from "$components/ui/Button.svelte"; import Button from "$components/ui/Button.svelte";
import { textsState } from "$states/texts.svelte"; import { textsState } from "$states/texts.svelte";
import Plus from "~icons/lucide/plus"; import Plus from "~icons/lucide/plus";
import TextInlineEdit from "./TextInlineEdit.svelte"; import TextInlineEdit from "./TextInlineEdit.svelte";
import TextInput from "./TextInput.svelte"; import TextInput from "./TextInput.svelte";
let text: string = $state(""); let text: string = $state("");
function addText() { function addText() {
textsState.addText(text); textsState.addText(text);
text = ""; text = "";
} }
function deleteText(id: number) { function deleteText(id: number) {
textsState.removeText(id); textsState.removeText(id);
} }
</script> </script>
<Card title="Тексты панелей"> <Card title="Тексты панелей">
<InputGroup> <InputGroup>
<TextInput ariaLabel="Input new text" bind:text onenter={addText} /> <TextInput ariaLabel="Input new text" bind:text onenter={addText} />
<Button icon={Plus} ariaLabel="Add text" onclick={addText} /> <Button icon={Plus} ariaLabel="Add text" onclick={addText} />
</InputGroup> </InputGroup>
<ul class="texts-list"> <ul class="texts-list">
{#each textsState.texts as { text, id } (id)} {#each textsState.texts as { text, id } (id)}
<TextInlineEdit {id} {text} ondelete={() => deleteText(id)} /> <TextInlineEdit {id} {text} ondelete={() => deleteText(id)} />
{/each} {/each}
</ul> </ul>
</Card> </Card>
<style> <style>
.texts-list { .texts-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
} }
</style> </style>
+99 -99
View File
@@ -1,99 +1,99 @@
<script lang="ts"> <script lang="ts">
import { TextAlign, type TextAlignType } from "$lib/constants"; import { TextAlign, type TextAlignType } from "$lib/constants";
import TextAlignCenter from "~icons/lucide/text-align-center"; import TextAlignCenter from "~icons/lucide/text-align-center";
import TextAlignEnd from "~icons/lucide/text-align-end"; import TextAlignEnd from "~icons/lucide/text-align-end";
import TextAlignStart from "~icons/lucide/text-align-start"; import TextAlignStart from "~icons/lucide/text-align-start";
interface Props { interface Props {
align: TextAlignType; align: TextAlignType;
} }
let { align = $bindable(TextAlign.LEFT) }: Props = $props(); let { align = $bindable(TextAlign.LEFT) }: Props = $props();
</script> </script>
<div class="alignment-group"> <div class="alignment-group">
<label class="alignment-item"> <label class="alignment-item">
<input <input
type="radio" type="radio"
name="alignment" name="alignment"
value={TextAlign.LEFT} value={TextAlign.LEFT}
class="sr-only" class="sr-only"
bind:group={align} bind:group={align}
aria-label={`Set ${TextAlign.LEFT}`} aria-label={`Set ${TextAlign.LEFT}`}
/> />
<div class="alignment-button"><TextAlignStart /></div> <div class="alignment-button"><TextAlignStart /></div>
</label> </label>
<label class="alignment-item"> <label class="alignment-item">
<input <input
type="radio" type="radio"
name="alignment" name="alignment"
value={TextAlign.CENTER} value={TextAlign.CENTER}
class="sr-only" class="sr-only"
bind:group={align} bind:group={align}
aria-label={`Set ${TextAlign.CENTER}`} aria-label={`Set ${TextAlign.CENTER}`}
/> />
<div class="alignment-button"><TextAlignCenter /></div> <div class="alignment-button"><TextAlignCenter /></div>
</label> </label>
<label class="alignment-item"> <label class="alignment-item">
<input <input
type="radio" type="radio"
name="alignment" name="alignment"
value={TextAlign.RIGHT} value={TextAlign.RIGHT}
class="sr-only" class="sr-only"
bind:group={align} bind:group={align}
aria-label={`Set ${TextAlign.RIGHT}`} aria-label={`Set ${TextAlign.RIGHT}`}
/> />
<div class="alignment-button"><TextAlignEnd /></div> <div class="alignment-button"><TextAlignEnd /></div>
</label> </label>
</div> </div>
<style> <style>
.alignment-group { .alignment-group {
display: flex; display: flex;
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;
} }
.alignment-button { .alignment-button {
flex-grow: 1; flex-grow: 1;
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
padding: 6px 12px; padding: 6px 12px;
cursor: pointer; cursor: pointer;
color: var(--text-muted); color: var(--text-muted);
transition: all 0.2s ease; transition: all 0.2s ease;
text-align: center; text-align: center;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.alignment-button:hover { .alignment-button:hover {
color: var(--text-main); color: var(--text-main);
background: var(--surface-subtle); background: var(--surface-subtle);
} }
.alignment-item { .alignment-item {
flex-grow: 1; flex-grow: 1;
& input { & input {
position: absolute; position: absolute;
opacity: 0; opacity: 0;
width: 0; width: 0;
height: 0; height: 0;
} }
&:first-child .alignment-button { &:first-child .alignment-button {
border-top-left-radius: var(--radius); border-top-left-radius: var(--radius);
border-bottom-left-radius: var(--radius); border-bottom-left-radius: var(--radius);
} }
&:last-child .alignment-button { &:last-child .alignment-button {
border-top-right-radius: var(--radius); border-top-right-radius: var(--radius);
border-bottom-right-radius: var(--radius); border-bottom-right-radius: var(--radius);
} }
} }
.alignment-item input:checked + .alignment-button { .alignment-item input:checked + .alignment-button {
background: var(--action-primary); background: var(--action-primary);
color: var(--text-action); color: var(--text-action);
} }
</style> </style>
+25 -25
View File
@@ -1,25 +1,25 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
text: string | number; text: string | number;
} }
let { text }: Props = $props(); let { text }: Props = $props();
</script> </script>
<span class="badge">{text}</span> <span class="badge">{text}</span>
<style> <style>
.badge { .badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 20px; min-width: 20px;
height: 20px; height: 20px;
padding: 0 6px; padding: 0 6px;
background: var(--action-primary); background: var(--action-primary);
color: var(--text-action); color: var(--text-action);
border-radius: 10px; border-radius: 10px;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
} }
</style> </style>
+115 -115
View File
@@ -1,115 +1,115 @@
<script lang="ts"> <script lang="ts">
import type { Component } from "svelte"; import type { Component } from "svelte";
import type { MouseEventHandler } from "svelte/elements"; import type { MouseEventHandler } from "svelte/elements";
interface Props { interface Props {
icon: Component; icon: Component;
ariaLabel: string; ariaLabel: string;
onclick?: MouseEventHandler<HTMLButtonElement>; onclick?: MouseEventHandler<HTMLButtonElement>;
disabled?: boolean; disabled?: boolean;
label?: string; label?: string;
type?: "primary" | "secondary" | "danger" | "outline" | "mini"; type?: "primary" | "secondary" | "danger" | "outline" | "mini";
extra?: "grow"; extra?: "grow";
} }
let { let {
icon: Icon, icon: Icon,
ariaLabel, ariaLabel,
onclick = () => {}, onclick = () => {},
disabled = false, disabled = false,
label = "", label = "",
type = "primary", type = "primary",
extra, extra,
}: Props = $props(); }: Props = $props();
</script> </script>
<button <button
class="btn" class="btn"
class:btn-primary={type === "primary"} class:btn-primary={type === "primary"}
class:btn-secondary={type === "secondary"} class:btn-secondary={type === "secondary"}
class:btn-outline={type === "outline"} class:btn-outline={type === "outline"}
class:btn-danger={type === "danger"} class:btn-danger={type === "danger"}
class:btn-mini={type === "mini"} class:btn-mini={type === "mini"}
class:grow={extra === "grow"} class:grow={extra === "grow"}
{disabled} {disabled}
{onclick} {onclick}
aria-label={ariaLabel} aria-label={ariaLabel}
> >
<Icon /> <Icon />
{label} {label}
</button> </button>
<style> <style>
.btn { .btn {
--btn-main: var(--action-primary); --btn-main: var(--action-primary);
--btn-hover: var(--action-primary-hover); --btn-hover: var(--action-primary-hover);
padding: 10px 16px; padding: 10px 16px;
border: none; border: none;
border-radius: var(--radius); border-radius: var(--radius);
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: var(--transition); transition: var(--transition);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
font-family: inherit; font-family: inherit;
white-space: nowrap; white-space: nowrap;
background-color: var(--btn-main); background-color: var(--btn-main);
color: oklch(from var(--btn-main) 99% 0.05 h); color: var(--text-action);
} }
.btn :global(svg) { .btn :global(svg) {
width: 16px; width: 16px;
height: 16px; height: 16px;
} }
.btn:disabled { .btn:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.btn:hover:not(:disabled) { .btn:hover:not(:disabled) {
background-color: var(--btn-hover); background-color: var(--btn-hover);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
} }
.btn-secondary { .btn-secondary {
--btn-main: var(--action-secondary); --btn-main: var(--action-secondary);
--btn-hover: var(--action-secondary-hover); --btn-hover: var(--action-secondary-hover);
} }
.btn-outline { .btn-outline {
background: transparent; background: transparent;
color: var(--text-main); color: var(--text-main);
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
} }
.btn-outline:hover:not(:disabled) { .btn-outline:hover:not(:disabled) {
background: var(--surface-hover); background: var(--surface-hover);
border-color: var(--border-strong); border-color: var(--border-strong);
color: var(--text-main); color: var(--text-main);
} }
.btn-danger { .btn-danger {
--btn-main: var(--danger-base); --btn-main: var(--danger-base);
} }
.btn-mini { .btn-mini {
width: 32px; width: 32px;
height: 32px; height: 32px;
padding: 0; padding: 0;
border: 1px solid var(--border-main); border: 1px solid var(--border-main);
border-radius: var(--radius); border-radius: var(--radius);
justify-content: center; justify-content: center;
} }
.btn-mini:hover:not(:disabled) { .btn-mini:hover:not(:disabled) {
border-color: var(--border-main); border-color: var(--border-main);
} }
.grow { .grow {
flex-grow: 1; flex-grow: 1;
justify-content: center; justify-content: center;
} }
</style> </style>
+40 -40
View File
@@ -1,40 +1,40 @@
<script lang="ts"> <script lang="ts">
import type { HexColor } from "$lib/types"; import type { HexColor } from "$lib/types";
interface Props { interface Props {
value: HexColor; value: HexColor;
} }
let { value = $bindable() }: Props = $props(); let { value = $bindable() }: Props = $props();
</script> </script>
<input type="color" bind:value class="color-input" /> <input type="color" bind:value class="color-input" />
<span class="color-value">{value}</span> <span class="color-value">{value}</span>
<style> <style>
.color-input { .color-input {
width: 40px; width: 40px;
height: 32px; height: 32px;
border: 1px solid var(--border-main); border: 1px solid var(--border-main);
border-radius: var(--radius); border-radius: var(--radius);
cursor: pointer; cursor: pointer;
transition: var(--transition); transition: var(--transition);
background: var(--surface-main); background: var(--surface-main);
padding: 0px 8px; padding: 0px 8px;
flex: 1; flex: 1;
} }
.color-input:hover { .color-input:hover {
border-color: var(--action-primary); border-color: var(--action-primary);
} }
.color-value { .color-value {
font-family: "Courier New", monospace; font-family: "Courier New", monospace;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
color: var(--text-muted); color: var(--text-muted);
padding: 4px 8px; padding: 4px 8px;
background: var(--surface-main); background: var(--surface-main);
border-radius: 4px; border-radius: 4px;
} }
</style> </style>
+68 -68
View File
@@ -1,68 +1,68 @@
<script lang="ts"> <script lang="ts">
import type { ChangeEventHandler } from "svelte/elements"; import type { ChangeEventHandler } from "svelte/elements";
interface Props { interface Props {
value: number; value: number;
min?: number; min?: number;
max?: number; max?: number;
step?: number; step?: number;
onchange?: ChangeEventHandler<HTMLInputElement>; onchange?: ChangeEventHandler<HTMLInputElement>;
} }
let { value = $bindable(), min = 0, max = 100, step = 1, onchange = () => {} }: Props = $props(); let { value = $bindable(), min = 0, max = 100, step = 1, onchange = () => {} }: Props = $props();
</script> </script>
<input type="range" {min} {max} {step} bind:value class="slider" {onchange} /> <input type="range" {min} {max} {step} bind:value class="slider" {onchange} />
<span class="value-display">{value}</span> <span class="value-display">{value}</span>
<style> <style>
.slider { .slider {
flex: 1; flex: 1;
height: 4px; height: 4px;
border-radius: 2px; border-radius: 2px;
background: var(--surface-control); background: var(--surface-control);
outline: none; outline: none;
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
} }
.slider::-webkit-slider-thumb { .slider::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
width: 16px; width: 16px;
height: 16px; height: 16px;
border-radius: 50%; border-radius: 50%;
background: var(--action-primary); background: var(--action-primary);
cursor: pointer; cursor: pointer;
transition: var(--transition); transition: var(--transition);
} }
.slider::-webkit-slider-thumb:hover { .slider::-webkit-slider-thumb:hover {
transform: scale(1.1); transform: scale(1.1);
} }
.slider::-moz-range-thumb { .slider::-moz-range-thumb {
width: 16px; width: 16px;
height: 16px; height: 16px;
border-radius: 50%; border-radius: 50%;
background: var(--action-primary); background: var(--action-primary);
cursor: pointer; cursor: pointer;
border: none; border: none;
transition: var(--transition); transition: var(--transition);
} }
.slider::-moz-range-thumb:hover { .slider::-moz-range-thumb:hover {
transform: scale(1.1); transform: scale(1.1);
} }
.value-display { .value-display {
color: var(--text-main); color: var(--text-main);
font-weight: 500; font-weight: 500;
min-width: 40px; min-width: 40px;
text-align: right; text-align: right;
font-size: 13px; font-size: 13px;
padding: 4px 8px; padding: 4px 8px;
background: var(--surface-main); background: var(--surface-main);
border-radius: 4px; border-radius: 4px;
} }
</style> </style>
+38 -38
View File
@@ -1,38 +1,38 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
value: string; value: string;
} }
let { value = $bindable() }: Props = $props(); let { value = $bindable() }: Props = $props();
</script> </script>
<select class="select-input" bind:value> <select class="select-input" bind:value>
<option value="Arial">Arial</option> <option value="Arial">Arial</option>
<option value="Verdana">Verdana</option> <option value="Verdana">Verdana</option>
<option value="Georgia">Georgia</option> <option value="Georgia">Georgia</option>
<option value="Times New Roman">Times New Roman</option> <option value="Times New Roman">Times New Roman</option>
<option value="Courier New">Courier New</option> <option value="Courier New">Courier New</option>
<option value="Impact">Impact</option> <option value="Impact">Impact</option>
<option value="Comic Sans MS">Comic Sans MS</option> <option value="Comic Sans MS">Comic Sans MS</option>
<option value="Trebuchet MS">Trebuchet MS</option> <option value="Trebuchet MS">Trebuchet MS</option>
</select> </select>
<style> <style>
.select-input { .select-input {
padding: 8px 10px; padding: 8px 10px;
border: 1px solid var(--border-main); border: 1px solid var(--border-main);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--surface-main); background: var(--surface-main);
color: var(--text-main); color: var(--text-main);
font-size: 13px; font-size: 13px;
cursor: pointer; cursor: pointer;
transition: var(--transition); transition: var(--transition);
font-family: inherit; font-family: inherit;
width: 100%; width: 100%;
} }
.select-input:focus { .select-input:focus {
outline: none; outline: none;
border-color: var(--border-strong); border-color: var(--border-strong);
} }
</style> </style>
+77 -77
View File
@@ -1,77 +1,77 @@
// Application Constants // Application Constants
// ===== PANEL SETTINGS ===== // ===== PANEL SETTINGS =====
export const PANEL_SETTINGS = { export const PANEL_SETTINGS = {
PANEL_WIDTH: 320, PANEL_WIDTH: 320,
PANEL_HEIGHT_DEFAULT: 100, PANEL_HEIGHT_DEFAULT: 100,
PANEL_HEIGHT_MAX: 200, PANEL_HEIGHT_MAX: 200,
DEFAULT_BACKGROUND_IMAGE: "./backgrounds/b1.jpg", DEFAULT_BACKGROUND_IMAGE: "./backgrounds/b1.jpg",
} as const; } as const;
// ===== TYPOGRAPHY ===== // ===== TYPOGRAPHY =====
export const TYPOGRAPHY = { export const TYPOGRAPHY = {
FONT_FAMILY_DEFAULT: "Arial", FONT_FAMILY_DEFAULT: "Arial",
FONT_FAMILIES: [ FONT_FAMILIES: [
"Arial", "Arial",
"Verdana", "Verdana",
"Georgia", "Georgia",
"Times New Roman", "Times New Roman",
"Courier New", "Courier New",
"Impact", "Impact",
"Comic Sans MS", "Comic Sans MS",
"Trebuchet MS", "Trebuchet MS",
], ],
// 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: 32, FONT_SIZE_DEFAULT: 32,
// 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_MAX: 100, PADDING_X_MAX: 100,
// Vertical offset for range inputs // Vertical offset for range inputs
VERTICAL_OFFSET_MAX: 100, VERTICAL_OFFSET_MAX: 100,
VERTICAL_OFFSET_MIN: -100, VERTICAL_OFFSET_MIN: -100,
// Colors // Colors
TEXT_COLOR_DEFAULT: "#ffffff", TEXT_COLOR_DEFAULT: "#ffffff",
} as const; } as const;
// ===== IMAGE SETTINGS ===== // ===== IMAGE SETTINGS =====
export const IMAGE_SETTINGS = { export const IMAGE_SETTINGS = {
MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB
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,
} as const; } as const;
export const SlideDirection = { export const SlideDirection = {
NEXT: "next", NEXT: "next",
PREV: "prev", PREV: "prev",
} as const; } as const;
export type SlideDirectionType = (typeof SlideDirection)[keyof typeof SlideDirection]; export type SlideDirectionType = (typeof SlideDirection)[keyof typeof SlideDirection];
export const TextAlign = { export const TextAlign = {
LEFT: "left", LEFT: "left",
CENTER: "center", CENTER: "center",
RIGHT: "right", RIGHT: "right",
} as const; } as const;
export type TextAlignType = (typeof TextAlign)[keyof typeof TextAlign]; export type TextAlignType = (typeof TextAlign)[keyof typeof TextAlign];
export const DEFAULT_TEXT_ALIGN: TextAlignType = TextAlign.CENTER; export const DEFAULT_TEXT_ALIGN: TextAlignType = TextAlign.CENTER;
export const Theme = { export const Theme = {
DARK: "dark", DARK: "dark",
LIGHT: "light", LIGHT: "light",
} as const; } as const;
export type ThemeType = (typeof Theme)[keyof typeof Theme]; export type ThemeType = (typeof Theme)[keyof typeof Theme];
export const TRANSITION_DURATION = import.meta.env.MODE === "test" ? 0 : 300; export const TRANSITION_DURATION = import.meta.env.MODE === "test" ? 0 : 300;
+37 -37
View File
@@ -1,37 +1,37 @@
export class AppError extends Error { export class AppError extends Error {
constructor( constructor(
message: string, message: string,
public code: string, public code: string,
public details?: unknown, public details?: unknown,
) { ) {
super(message); super(message);
this.name = "AppError"; this.name = "AppError";
if (details) this.details = details; if (details) this.details = details;
} }
} }
export class ImageError extends AppError { export class ImageError extends AppError {
constructor(message: string) { constructor(message: string) {
super(message, "IMAGE_ERROR"); super(message, "IMAGE_ERROR");
} }
} }
export class TextError extends AppError { export class TextError extends AppError {
constructor(message: string) { constructor(message: string) {
super(message, "TEXT_ERROR"); super(message, "TEXT_ERROR");
} }
} }
export class CanvasError extends AppError { export class CanvasError extends AppError {
constructor(message: string) { constructor(message: string) {
super(message, "CANVAS_ERROR"); super(message, "CANVAS_ERROR");
} }
} }
export class StorageError extends AppError { export class StorageError extends AppError {
constructor(message: string) { constructor(message: string) {
super(message, "STORAGE_ERROR"); super(message, "STORAGE_ERROR");
} }
} }
export type ErrorType = AppError | ImageError | TextError | CanvasError | StorageError; export type ErrorType = AppError | ImageError | TextError | CanvasError | StorageError;
+1 -1
View File
@@ -1 +1 @@
export type HexColor = `#${string}`; export type HexColor = `#${string}`;
+26 -26
View File
@@ -1,26 +1,26 @@
import { AppError } from "$lib/error.types"; import { AppError } from "$lib/error.types";
export function formatError(error: unknown, defaultMessage: string = "Произошла ошибка"): string { export function formatError(error: unknown, defaultMessage: string = "Произошла ошибка"): string {
if (error instanceof AppError || error instanceof Error) { if (error instanceof AppError || error instanceof Error) {
return `${defaultMessage}: ${error.message}`; return `${defaultMessage}: ${error.message}`;
} }
if (typeof error === "string") { if (typeof error === "string") {
return `${defaultMessage}: ${error}`; return `${defaultMessage}: ${error}`;
} }
return `${defaultMessage}: Произошла неизвестная ошибка`; return `${defaultMessage}: Произошла неизвестная ошибка`;
} }
export function createError(message: string, code: string, details?: unknown): AppError { export function createError(message: string, code: string, details?: unknown): AppError {
return new AppError(message, code, details); return new AppError(message, code, details);
} }
export function logError(error: unknown, context?: string): void { export function logError(error: unknown, context?: string): void {
console.error("Error occurred:", { console.error("Error occurred:", {
error, error,
context, context,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
stack: error instanceof Error ? error.stack : undefined, stack: error instanceof Error ? error.stack : undefined,
}); });
} }
+35 -35
View File
@@ -1,35 +1,35 @@
<script lang="ts"> <script lang="ts">
import AppHeader from "$components/layout/AppHeader.svelte"; import AppHeader from "$components/layout/AppHeader.svelte";
import { PANEL_SETTINGS } from "$lib/constants"; import { PANEL_SETTINGS } from "$lib/constants";
import { imageConfigState } from "$states/imageConfig.svelte"; import { imageConfigState } from "$states/imageConfig.svelte";
import { themeState } from "$states/theme.svelte"; import { themeState } from "$states/theme.svelte";
import { onMount, type Snippet } from "svelte"; import { onMount, type Snippet } from "svelte";
import "../app.css"; import "../app.css";
interface Props { interface Props {
children: Snippet; children: Snippet;
} }
$effect(() => { $effect(() => {
const newTheme = themeState.current; const newTheme = themeState.current;
document.documentElement.setAttribute("data-theme", newTheme); document.documentElement.setAttribute("data-theme", newTheme);
}); });
let { children }: Props = $props(); let { children }: Props = $props();
onMount(async () => { onMount(async () => {
await imageConfigState.uploadImageByLink(PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE); await imageConfigState.uploadImageByLink(PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
}); });
</script> </script>
<div class="container"> <div class="container">
<AppHeader /> <AppHeader />
{@render children()} {@render children()}
</div> </div>
<style> <style>
.container { .container {
max-width: 1400px; max-width: 1400px;
margin: 0 auto; margin: 0 auto;
} }
</style> </style>
+2 -2
View File
@@ -1,2 +1,2 @@
export const ssr = false; export const ssr = false;
export const prerender = true; export const prerender = true;
+23 -23
View File
@@ -1,23 +1,23 @@
<script lang="ts"> <script lang="ts">
import PanelBar from "$components/layout/PanelBar.svelte"; import PanelBar from "$components/layout/PanelBar.svelte";
import TextBar from "$components/layout/TextBar.svelte"; import TextBar from "$components/layout/TextBar.svelte";
</script> </script>
<div class="main-grid"> <div class="main-grid">
<TextBar /> <TextBar />
<PanelBar /> <PanelBar />
</div> </div>
<style> <style>
.main-grid { .main-grid {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 16px; gap: 16px;
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
.main-grid { .main-grid {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
} }
</style> </style>
+83 -83
View File
@@ -1,83 +1,83 @@
import { ImageError } from "$lib/error.types"; import { ImageError } from "$lib/error.types";
import { formatError, logError } from "$lib/utils/errorUtils"; import { formatError, logError } from "$lib/utils/errorUtils";
import { saveAs } from "file-saver"; import { saveAs } from "file-saver";
import JSZip from "jszip"; import JSZip from "jszip";
import { Stage } from "konva/lib/Stage"; import { Stage } from "konva/lib/Stage";
export type DownloadResult = export type DownloadResult =
| { | {
success: true; success: true;
} }
| { | {
success: false; success: false;
error: string; error: string;
}; };
export interface DownloadItem { export interface DownloadItem {
filename: string; filename: string;
stage: Stage; stage: Stage;
} }
export class DownloadService { export class DownloadService {
async downloadPanel(konvaStage: Stage, label: string): Promise<DownloadResult> { async downloadPanel(konvaStage: Stage, label: string): Promise<DownloadResult> {
try { try {
const blob = await this.stageToBlob(konvaStage); const blob = await this.stageToBlob(konvaStage);
const filename = `${label}.png`; const filename = `${label}.png`;
saveAs(blob, filename); saveAs(blob, filename);
return { return {
success: true, success: true,
}; };
} catch (error) { } catch (error) {
logError(error, "Ошибка сохранения панели"); logError(error, "Ошибка сохранения панели");
return { return {
success: false, success: false,
error: formatError(error, "Ошибка сохранения панели"), error: formatError(error, "Ошибка сохранения панели"),
}; };
} }
} }
async downloadAll(panels: Array<DownloadItem>): Promise<DownloadResult> { async downloadAll(panels: Array<DownloadItem>): Promise<DownloadResult> {
try { try {
const zip = new JSZip(); const zip = new JSZip();
for (let panel of panels) { for (const panel of panels) {
const blob = await this.stageToBlob(panel.stage); const blob = await this.stageToBlob(panel.stage);
zip.file(`${panel.filename}.png`, blob); zip.file(`${panel.filename}.png`, blob);
} }
const zipBlob = await zip.generateAsync({ type: "blob" }); const zipBlob = await zip.generateAsync({ type: "blob" });
saveAs(zipBlob, "panels.zip"); saveAs(zipBlob, "panels.zip");
return { return {
success: true, success: true,
}; };
} catch (error) { } catch (error) {
logError(error, "Ошибка сохранения архива"); logError(error, "Ошибка сохранения архива");
return { return {
success: false, success: false,
error: formatError(error, "Ошибка сохранения архива"), error: formatError(error, "Ошибка сохранения архива"),
}; };
} }
} }
private async stageToBlob(konvaStage: Stage): Promise<Blob> { private async stageToBlob(konvaStage: Stage): Promise<Blob> {
if (!konvaStage || typeof konvaStage.toBlob !== "function") { if (!konvaStage || typeof konvaStage.toBlob !== "function") {
throw new ImageError("Konva Stage не найден или не поддерживает toBlob"); throw new ImageError("Konva Stage не найден или не поддерживает toBlob");
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
konvaStage.toBlob({ konvaStage.toBlob({
callback: (blob: Blob | null) => { callback: (blob: Blob | null) => {
if (blob) { if (blob) {
resolve(blob); resolve(blob);
} else { } else {
reject(new ImageError("Не удалось создать изображение из Konva Stage")); reject(new ImageError("Не удалось создать изображение из Konva Stage"));
} }
}, },
}); });
}); });
} }
} }
export const downloadService = new DownloadService(); export const downloadService = new DownloadService();
+98 -98
View File
@@ -1,98 +1,98 @@
export type ImageConfig = { export type ImageConfig = {
image: HTMLImageElement | undefined; image: HTMLImageElement | undefined;
imageLink: string; imageLink: string;
imageReady: boolean; imageReady: boolean;
cropLeft: number; cropLeft: number;
cropTop: number; cropTop: number;
cropRight: number; cropRight: number;
cropBottom: number; cropBottom: number;
}; };
export class ImageConfigState { export class ImageConfigState {
image = $state<HTMLImageElement | undefined>(undefined); image = $state<HTMLImageElement | undefined>(undefined);
imageLink = $state(""); imageLink = $state("");
imageReady = $state(false); imageReady = $state(false);
cropLeft = $state(0); cropLeft = $state(0);
cropTop = $state(0); cropTop = $state(0);
cropRight = $state(0); cropRight = $state(0);
cropBottom = $state(0); cropBottom = $state(0);
#currentAbortController: AbortController | null = null; #currentAbortController: AbortController | null = null;
private cleanup() { private cleanup() {
if (this.#currentAbortController) { if (this.#currentAbortController) {
this.#currentAbortController.abort(); this.#currentAbortController.abort();
this.#currentAbortController = null; this.#currentAbortController = null;
} }
if (this.image) { if (this.image) {
this.image.onload = null; this.image.onload = null;
this.image.onerror = null; this.image.onerror = null;
this.image.src = ""; this.image.src = "";
this.image = undefined; this.image = undefined;
} }
} }
async uploadImageByLink(link: string): Promise<void> { async uploadImageByLink(link: string): Promise<void> {
this.cleanup(); this.cleanup();
this.imageReady = false; this.imageReady = false;
this.imageLink = link; this.imageLink = link;
this.#currentAbortController = new AbortController(); this.#currentAbortController = new AbortController();
const { signal } = this.#currentAbortController; const { signal } = this.#currentAbortController;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
img.crossOrigin = "anonymous"; img.crossOrigin = "anonymous";
const onFinished = () => { const onFinished = () => {
img.onload = null; img.onload = null;
img.onerror = null; img.onerror = null;
}; };
img.onload = () => { img.onload = () => {
if (signal.aborted) return; if (signal.aborted) return;
onFinished(); onFinished();
this.image = img; this.image = img;
this.imageReady = true; this.imageReady = true;
resolve(); resolve();
}; };
img.onerror = () => { img.onerror = () => {
if (signal.aborted) return; if (signal.aborted) return;
onFinished(); onFinished();
this.imageReady = false; this.imageReady = false;
reject(new Error(`Failed to load image: ${link}`)); reject(new Error(`Failed to load image: ${link}`));
}; };
signal.addEventListener( signal.addEventListener(
"abort", "abort",
() => { () => {
onFinished(); onFinished();
img.src = ""; img.src = "";
reject(new DOMException("Aborted", "AbortError")); reject(new DOMException("Aborted", "AbortError"));
}, },
{ once: true }, { once: true },
); );
img.src = link; img.src = link;
}); });
} }
reset() { reset() {
this.cleanup(); this.cleanup();
this.imageLink = ""; this.imageLink = "";
this.imageReady = false; this.imageReady = false;
this.cropLeft = 0; this.cropLeft = 0;
this.cropTop = 0; this.cropTop = 0;
this.cropRight = 0; this.cropRight = 0;
this.cropBottom = 0; this.cropBottom = 0;
} }
destroy() { destroy() {
this.cleanup(); this.cleanup();
} }
} }
export const imageConfigState = new ImageConfigState(); export const imageConfigState = new ImageConfigState();
+3 -3
View File
@@ -1,3 +1,3 @@
import type { Stage } from "svelte-konva"; import type { Stage } from "svelte-konva";
export const konvaAllStagesState: Array<Stage> = $state([]); export const konvaAllStagesState: Array<Stage> = $state([]);
+16 -16
View File
@@ -1,16 +1,16 @@
import type { Stage } from "svelte-konva"; import type { Stage } from "svelte-konva";
function createState() { function createState() {
let stage: Stage | undefined = $state(undefined); let stage: Stage | undefined = $state(undefined);
return { return {
get stage(): Stage | undefined { get stage(): Stage | undefined {
return stage; return stage;
}, },
set stage(newStage: Stage) { set stage(newStage: Stage) {
stage = newStage; stage = newStage;
}, },
}; };
} }
export const konvaStageState = createState(); export const konvaStageState = createState();
+44 -48
View File
@@ -1,48 +1,44 @@
import { browser } from "$app/environment"; import { browser } from "$app/environment";
export const STATE_DATA = Symbol("state-data"); export const STATE_DATA = Symbol("state-data");
export const DEBOUNCE_DURATION = import.meta.env.MODE === "test" ? 0 : 500; export const DEBOUNCE_DURATION = import.meta.env.MODE === "test" ? 0 : 500;
type OnlyData<T> = { export interface Persistable<D> {
[K in keyof T as T[K] extends Function ? never : K]: T[K]; [STATE_DATA]: D;
}; }
export interface Persistable<D> { export function withPersistence<D extends object, T extends Persistable<D>>(
[STATE_DATA]: D; key: string,
} state: T,
debounceMs = DEBOUNCE_DURATION,
export function withPersistence<D extends object, T extends Persistable<D>>( ): T {
key: string, if (!browser) return state;
state: T,
debounceMs = DEBOUNCE_DURATION, const saved = localStorage.getItem(key);
): T { if (saved) {
if (!browser) return state; try {
const parsed = JSON.parse(saved);
const saved = localStorage.getItem(key);
if (saved) { if (parsed) {
try { state[STATE_DATA] = parsed;
const parsed = JSON.parse(saved); }
} catch (e) {
if (parsed) { console.error(`Error repairing state for ${key}`, e);
state[STATE_DATA] = parsed; }
} }
} catch (e) {
console.error(`Error repairing state for ${key}`, e); $effect.root(() => {
} $effect(() => {
} const data = JSON.stringify($state.snapshot(state[STATE_DATA]));
$effect.root(() => { if (debounceMs > 0) {
$effect(() => { const timeout = setTimeout(() => localStorage.setItem(key, data), debounceMs);
const data = JSON.stringify($state.snapshot(state[STATE_DATA])); return () => clearTimeout(timeout);
} else {
if (debounceMs > 0) { localStorage.setItem(key, data);
const timeout = setTimeout(() => localStorage.setItem(key, data), debounceMs); }
return () => clearTimeout(timeout); });
} else { });
localStorage.setItem(key, data);
} return state;
}); }
});
return state;
}
+83 -83
View File
@@ -1,83 +1,83 @@
import { type TextAlignType } from "$lib/constants"; import { type TextAlignType } from "$lib/constants";
import type { HexColor } from "$lib/types"; import type { HexColor } from "$lib/types";
import { STATE_DATA, withPersistence } from "./persisted.svelte"; import { STATE_DATA, withPersistence } from "./persisted.svelte";
export type TextConfig = { export type TextConfig = {
fontSize: number; fontSize: number;
fontFamily: string; fontFamily: string;
color: HexColor; color: HexColor;
align: TextAlignType; align: TextAlignType;
paddingX: number; paddingX: number;
offsetY: number; offsetY: number;
}; };
function createState() { function createState() {
const defaults: TextConfig = { const defaults: TextConfig = {
fontSize: 24, fontSize: 24,
fontFamily: "Arial", fontFamily: "Arial",
color: "#ffffff", color: "#ffffff",
align: "center", align: "center",
paddingX: 10, paddingX: 10,
offsetY: 0, offsetY: 0,
}; };
let state: TextConfig = $state({ ...defaults }); const state: TextConfig = $state({ ...defaults });
return { return {
get fontSize() { get fontSize() {
return state.fontSize; return state.fontSize;
}, },
set fontSize(value: number) { set fontSize(value: number) {
state.fontSize = value; state.fontSize = value;
}, },
get fontFamily() { get fontFamily() {
return state.fontFamily; return state.fontFamily;
}, },
set fontFamily(fontFamily: string) { set fontFamily(fontFamily: string) {
state.fontFamily = fontFamily; state.fontFamily = fontFamily;
}, },
get color() { get color() {
return state.color; return state.color;
}, },
set color(color: HexColor) { set color(color: HexColor) {
state.color = color; state.color = color;
}, },
get align() { get align() {
return state.align; return state.align;
}, },
set align(align: TextAlignType) { set align(align: TextAlignType) {
state.align = align; state.align = align;
}, },
get paddingX() { get paddingX() {
return state.paddingX; return state.paddingX;
}, },
set paddingX(paddingX: number) { set paddingX(paddingX: number) {
state.paddingX = paddingX; state.paddingX = paddingX;
}, },
get offsetY() { get offsetY() {
return state.offsetY; return state.offsetY;
}, },
set offsetY(offsetY: number) { set offsetY(offsetY: number) {
state.offsetY = offsetY; state.offsetY = offsetY;
}, },
get [STATE_DATA]() { get [STATE_DATA]() {
return { return {
fontSize: state.fontSize, fontSize: state.fontSize,
fontFamily: state.fontFamily, fontFamily: state.fontFamily,
color: state.color, color: state.color,
align: state.align, align: state.align,
paddingX: state.paddingX, paddingX: state.paddingX,
offsetY: state.offsetY, offsetY: state.offsetY,
}; };
}, },
set [STATE_DATA](newConfig: TextConfig) { set [STATE_DATA](newConfig: TextConfig) {
state.fontSize = newConfig.fontSize; state.fontSize = newConfig.fontSize;
state.fontFamily = newConfig.fontFamily; state.fontFamily = newConfig.fontFamily;
state.color = newConfig.color; state.color = newConfig.color;
state.align = newConfig.align; state.align = newConfig.align;
state.paddingX = newConfig.paddingX; state.paddingX = newConfig.paddingX;
state.offsetY = newConfig.offsetY; state.offsetY = newConfig.offsetY;
}, },
}; };
} }
export const textConfigState = withPersistence("text-config", createState()); export const textConfigState = withPersistence("text-config", createState());
+43 -40
View File
@@ -1,40 +1,43 @@
import { STATE_DATA, withPersistence } from "./persisted.svelte"; import { STATE_DATA, withPersistence } from "./persisted.svelte";
export interface TextItem { export interface TextItem {
text: string; text: string;
id: number; id: number;
} }
const defaultTexts: Array<TextItem> = ["About me", "Links", "Projects"].map((text, idx) => ({ text, id: idx })); const defaultTexts: Array<TextItem> = ["About me", "Links", "Projects"].map((text, idx) => ({
text,
export function createState() { id: idx,
let texts: Array<TextItem> = $state(defaultTexts); }));
let nextId = $state(defaultTexts.length);
export function createState() {
return { let texts: Array<TextItem> = $state(defaultTexts);
get texts() { let nextId = $state(defaultTexts.length);
return texts;
}, return {
addText(text: string) { get texts() {
if (text.trim().length === 0) return; return texts;
texts.push({ text, id: nextId }); },
nextId++; addText(text: string) {
}, if (text.trim().length === 0) return;
removeText(id: number) { texts.push({ text, id: nextId });
texts = texts.filter((textItem) => textItem.id !== id); nextId++;
}, },
clear() { removeText(id: number) {
texts = []; texts = texts.filter((textItem) => textItem.id !== id);
nextId = 0; },
}, clear() {
get [STATE_DATA]() { texts = [];
return texts.map(({ text }) => text); nextId = 0;
}, },
set [STATE_DATA](newTexts: Array<string>) { get [STATE_DATA]() {
texts = newTexts.map((text, idx) => ({ text, id: idx })); return texts.map(({ text }) => text);
nextId = newTexts.length; },
}, set [STATE_DATA](newTexts: Array<string>) {
}; texts = newTexts.map((text, idx) => ({ text, id: idx }));
} nextId = newTexts.length;
},
export const textsState = withPersistence("texts", createState()); };
}
export const textsState = withPersistence("texts", createState());
+28 -28
View File
@@ -1,28 +1,28 @@
import { Theme, type ThemeType } from "$lib/constants"; import { Theme, type ThemeType } from "$lib/constants";
import { STATE_DATA, withPersistence } from "./persisted.svelte"; import { STATE_DATA, withPersistence } from "./persisted.svelte";
function createState() { function createState() {
let current: ThemeType = $state(Theme.LIGHT); let current: ThemeType = $state(Theme.LIGHT);
return { return {
get current() { get current() {
return current; return current;
}, },
set current(value: ThemeType) { set current(value: ThemeType) {
current = value; current = value;
}, },
toggle() { toggle() {
current = current === Theme.DARK ? Theme.LIGHT : Theme.DARK; current = current === Theme.DARK ? Theme.LIGHT : Theme.DARK;
}, },
get [STATE_DATA]() { get [STATE_DATA]() {
return { return {
current, current,
}; };
}, },
set [STATE_DATA](data: { current: ThemeType }) { set [STATE_DATA](data: { current: ThemeType }) {
current = data.current; current = data.current;
}, },
}; };
} }
export const themeState = withPersistence("theme", createState()); export const themeState = withPersistence("theme", createState());
+3 -3
View File
@@ -1,3 +1,3 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import "vitest-canvas-mock"; import "vitest-canvas-mock";
import "web-animations-js"; import "web-animations-js";
+10 -10
View File
@@ -1,10 +1,10 @@
import CropInline from "$components/image/CropInline.svelte"; import CropInline from "$components/image/CropInline.svelte";
import { render } from "@testing-library/svelte"; import { render } from "@testing-library/svelte";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
describe("CropInline.svelte", () => { describe("CropInline.svelte", () => {
it("should render without crashing", () => { it("should render without crashing", () => {
const { container } = render(CropInline); const { container } = render(CropInline);
expect(container).toBeInTheDocument(); expect(container).toBeInTheDocument();
}); });
}); });
@@ -1,9 +1,9 @@
import ImageManager from "$components/image/ImageManager.svelte"; import ImageManager from "$components/image/ImageManager.svelte";
import { render } from "@testing-library/svelte"; import { render } from "@testing-library/svelte";
import { describe, it } from "vitest"; import { describe, it } from "vitest";
describe("ImageManager.svelte", () => { describe("ImageManager.svelte", () => {
it("should render without crashing", () => { it("should render without crashing", () => {
render(ImageManager); render(ImageManager);
}); });
}); });
+25 -25
View File
@@ -1,25 +1,25 @@
import AppHeader from "$components/layout/AppHeader.svelte"; import AppHeader from "$components/layout/AppHeader.svelte";
import { themeState } from "$states/theme.svelte"; import { themeState } from "$states/theme.svelte";
import { render, screen } from "@testing-library/svelte"; import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
describe("AppHeader.svelte", () => { describe("AppHeader.svelte", () => {
beforeEach(() => { beforeEach(() => {
themeState.current = "dark"; themeState.current = "dark";
}); });
it("should toggle theme on button click", async () => { it("should toggle theme on button click", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(AppHeader); render(AppHeader);
const toggleButton = screen.getByRole("button", { name: /toggle theme/i }); const toggleButton = screen.getByRole("button", { name: /toggle theme/i });
themeState.current = "dark"; themeState.current = "dark";
await user.click(toggleButton); await user.click(toggleButton);
expect(themeState.current).toBe("light"); expect(themeState.current).toBe("light");
await user.click(toggleButton); await user.click(toggleButton);
expect(themeState.current).toBe("dark"); expect(themeState.current).toBe("dark");
}); });
}); });
+11 -11
View File
@@ -1,11 +1,11 @@
import { render, screen } from "@testing-library/svelte"; import { render, screen } from "@testing-library/svelte";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import CardTest from "./CardTest.svelte"; import CardTest from "./CardTest.svelte";
describe("Card.svelte", () => { describe("Card.svelte", () => {
it("should render without crashing", () => { it("should render without crashing", () => {
render(CardTest); render(CardTest);
expect(screen.getByText("Test Title")).toBeInTheDocument(); expect(screen.getByText("Test Title")).toBeInTheDocument();
expect(screen.getByTestId("card-content")).toBeInTheDocument(); expect(screen.getByTestId("card-content")).toBeInTheDocument();
}); });
}); });
+7 -7
View File
@@ -1,7 +1,7 @@
<script> <script>
import Card from "$components/layout/Card.svelte"; import Card from "$components/layout/Card.svelte";
</script> </script>
<Card title="Test Title"> <Card title="Test Title">
<div data-testid="card-content">Test content</div> <div data-testid="card-content">Test content</div>
</Card> </Card>
+12 -12
View File
@@ -1,12 +1,12 @@
import { render, screen } from "@testing-library/svelte"; import { render, screen } from "@testing-library/svelte";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import InputGroupTest from "./InputGroupTest.svelte"; import InputGroupTest from "./InputGroupTest.svelte";
describe("InputGroup.svelte", () => { describe("InputGroup.svelte", () => {
it("should render without crashing", () => { it("should render without crashing", () => {
render(InputGroupTest); render(InputGroupTest);
expect(screen.getByTestId("input1")).toBeInTheDocument(); expect(screen.getByTestId("input1")).toBeInTheDocument();
expect(screen.getByTestId("input2")).toBeInTheDocument(); expect(screen.getByTestId("input2")).toBeInTheDocument();
expect(screen.getByTestId("button")).toBeInTheDocument(); expect(screen.getByTestId("button")).toBeInTheDocument();
}); });
}); });
@@ -1,9 +1,9 @@
<script> <script>
import InputGroup from "$components/layout/InputGroup.svelte"; import InputGroup from "$components/layout/InputGroup.svelte";
</script> </script>
<InputGroup> <InputGroup>
<input type="text" data-testid="input1" /> <input type="text" data-testid="input1" />
<input type="text" data-testid="input2" /> <input type="text" data-testid="input2" />
<button data-testid="button">Кнопка</button> <button data-testid="button">Кнопка</button>
</InputGroup> </InputGroup>
+10 -10
View File
@@ -1,10 +1,10 @@
import PanelBar from "$components/layout/PanelBar.svelte"; import PanelBar from "$components/layout/PanelBar.svelte";
import { render } from "@testing-library/svelte"; import { render } from "@testing-library/svelte";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
describe("PanelBar.svelte", () => { describe("PanelBar.svelte", () => {
it("should render without crashing", () => { it("should render without crashing", () => {
const { container } = render(PanelBar); const { container } = render(PanelBar);
expect(container).toBeInTheDocument(); expect(container).toBeInTheDocument();
}); });
}); });
@@ -1,12 +1,12 @@
import { render, screen } from "@testing-library/svelte"; import { render, screen } from "@testing-library/svelte";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import SettingsGridTest from "./SettingsGridTest.svelte"; import SettingsGridTest from "./SettingsGridTest.svelte";
describe("SettingsGrid.svelte", () => { describe("SettingsGrid.svelte", () => {
it("should render without crashing", () => { it("should render without crashing", () => {
render(SettingsGridTest); render(SettingsGridTest);
expect(screen.getByTestId("item1")).toBeInTheDocument(); expect(screen.getByTestId("item1")).toBeInTheDocument();
expect(screen.getByTestId("item2")).toBeInTheDocument(); expect(screen.getByTestId("item2")).toBeInTheDocument();
expect(screen.getByTestId("item3")).toBeInTheDocument(); expect(screen.getByTestId("item3")).toBeInTheDocument();
}); });
}); });
@@ -1,9 +1,9 @@
<script> <script>
import SettingsGrid from "$components/layout/SettingsGrid.svelte"; import SettingsGrid from "$components/layout/SettingsGrid.svelte";
</script> </script>
<SettingsGrid> <SettingsGrid>
<div data-testid="item1">Item 1</div> <div data-testid="item1">Item 1</div>
<div data-testid="item2">Item 2</div> <div data-testid="item2">Item 2</div>
<div data-testid="item3">Item 3</div> <div data-testid="item3">Item 3</div>
</SettingsGrid> </SettingsGrid>
@@ -1,11 +1,11 @@
import { render, screen } from "@testing-library/svelte"; import { render, screen } from "@testing-library/svelte";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import SettingsRowTest from "./SettingsRowTest.svelte"; import SettingsRowTest from "./SettingsRowTest.svelte";
describe("SettingsRow.svelte", () => { describe("SettingsRow.svelte", () => {
it("should render without crashing", () => { it("should render without crashing", () => {
render(SettingsRowTest); render(SettingsRowTest);
expect(screen.getByText("Test Label")).toBeInTheDocument(); expect(screen.getByText("Test Label")).toBeInTheDocument();
expect(screen.getByTestId("test-input")).toBeInTheDocument(); expect(screen.getByTestId("test-input")).toBeInTheDocument();
}); });
}); });
@@ -1,7 +1,7 @@
<script> <script>
import SettingsRow from "$components/layout/SettingsRow.svelte"; import SettingsRow from "$components/layout/SettingsRow.svelte";
</script> </script>
<SettingsRow label="Test Label"> <SettingsRow label="Test Label">
<input type="text" data-testid="test-input" /> <input type="text" data-testid="test-input" />
</SettingsRow> </SettingsRow>
+10 -10
View File
@@ -1,10 +1,10 @@
import TextBar from "$components/layout/TextBar.svelte"; import TextBar from "$components/layout/TextBar.svelte";
import { render } from "@testing-library/svelte"; import { render } from "@testing-library/svelte";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
describe("TextBar.svelte", () => { describe("TextBar.svelte", () => {
it("should render without crashing", () => { it("should render without crashing", () => {
const { container } = render(TextBar); const { container } = render(TextBar);
expect(container).toBeInTheDocument(); expect(container).toBeInTheDocument();
}); });
}); });
+32 -32
View File
@@ -1,32 +1,32 @@
import Preview from "$components/panel/Preview.svelte"; import Preview from "$components/panel/Preview.svelte";
import { cleanup, render } from "@testing-library/svelte"; import { cleanup, render } from "@testing-library/svelte";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
}); });
describe("Preview.svelte", () => { describe("Preview.svelte", () => {
it("should render without crashing", () => { it("should render without crashing", () => {
const { container } = render(Preview, { const { container } = render(Preview, {
props: { props: {
text: "Test text", text: "Test text",
stage: undefined, stage: undefined,
}, },
}); });
expect(container).toBeInTheDocument(); expect(container).toBeInTheDocument();
}); });
it("should have stage element", () => { it("should have stage element", () => {
const { container } = render(Preview, { const { container } = render(Preview, {
props: { props: {
text: "Test", text: "Test",
stage: undefined, stage: undefined,
}, },
}); });
const stage = container.querySelector("canvas"); const stage = container.querySelector("canvas");
expect(stage).toBeInTheDocument(); expect(stage).toBeInTheDocument();
}); });
}); });
+14 -14
View File
@@ -1,14 +1,14 @@
import PreviewAll from "$components/panel/PreviewAll.svelte"; import PreviewAll from "$components/panel/PreviewAll.svelte";
import { textsState } from "$states/texts.svelte"; import { textsState } from "$states/texts.svelte";
import { render } from "@testing-library/svelte"; import { render } from "@testing-library/svelte";
import { beforeEach, describe, it } from "vitest"; import { beforeEach, describe, it } from "vitest";
describe("PreviewAll.svelte", () => { describe("PreviewAll.svelte", () => {
beforeEach(() => { beforeEach(() => {
textsState.texts.length = 0; textsState.texts.length = 0;
}); });
it("should render without crashing", () => { it("should render without crashing", () => {
render(PreviewAll); render(PreviewAll);
}); });
}); });
@@ -1,46 +1,46 @@
import { SlideDirection } from "$lib/constants"; import { SlideDirection } from "$lib/constants";
import { render, screen } from "@testing-library/svelte"; import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import PreviewControlsTest from "./PreviewControlsTest.svelte"; import PreviewControlsTest from "./PreviewControlsTest.svelte";
describe("PreviewControls logic", () => { describe("PreviewControls logic", () => {
it("should increment current and update direction on next click", async () => { it("should increment current and update direction on next click", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(PreviewControlsTest, { props: { current: 0, max: 3 } }); render(PreviewControlsTest, { props: { current: 0, max: 3 } });
const nextBtn = screen.getByRole("button", { name: /next slide/i }); const nextBtn = screen.getByRole("button", { name: /next slide/i });
await user.click(nextBtn); await user.click(nextBtn);
expect(screen.getByTestId("current").textContent).toBe("1"); expect(screen.getByTestId("current").textContent).toBe("1");
expect(screen.getByTestId("direction").textContent).toBe(SlideDirection.NEXT); expect(screen.getByTestId("direction").textContent).toBe(SlideDirection.NEXT);
}); });
it("should decrement current on prev click", async () => { it("should decrement current on prev click", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(PreviewControlsTest, { props: { current: 2, max: 3 } }); render(PreviewControlsTest, { props: { current: 2, max: 3 } });
const prevBtn = screen.getByRole("button", { name: /previous slide/i }); const prevBtn = screen.getByRole("button", { name: /previous slide/i });
await user.click(prevBtn); await user.click(prevBtn);
expect(screen.getByTestId("current").textContent).toBe("1"); expect(screen.getByTestId("current").textContent).toBe("1");
}); });
it("should handle boundaries and disable buttons", async () => { it("should handle boundaries and disable buttons", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(PreviewControlsTest, { props: { current: 0, max: 2 } }); render(PreviewControlsTest, { props: { current: 0, max: 2 } });
const prevBtn = screen.getByRole("button", { name: /previous slide/i }); const prevBtn = screen.getByRole("button", { name: /previous slide/i });
const nextBtn = screen.getByRole("button", { name: /next slide/i }); const nextBtn = screen.getByRole("button", { name: /next slide/i });
expect(prevBtn).toBeDisabled(); expect(prevBtn).toBeDisabled();
await user.click(nextBtn); await user.click(nextBtn);
expect(screen.getByTestId("current").textContent).toBe("1"); expect(screen.getByTestId("current").textContent).toBe("1");
expect(nextBtn).toBeDisabled(); expect(nextBtn).toBeDisabled();
expect(prevBtn).not.toBeDisabled(); expect(prevBtn).not.toBeDisabled();
}); });
}); });
@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import PreviewControls from "$components/panel/PreviewControls.svelte"; import PreviewControls from "$components/panel/PreviewControls.svelte";
import { SlideDirection } from "$lib/constants"; import { SlideDirection } from "$lib/constants";
let { current = 0, max = 5 } = $props(); let { current = 0, max = 5 } = $props();
let direction = $state(SlideDirection.NEXT); let direction = $state(SlideDirection.NEXT);
</script> </script>
<PreviewControls bind:current bind:direction {max} /> <PreviewControls bind:current bind:direction {max} />
<div data-testid="current">{current}</div> <div data-testid="current">{current}</div>
<div data-testid="direction">{direction}</div> <div data-testid="direction">{direction}</div>
+103 -102
View File
@@ -1,102 +1,103 @@
import PreviewManager from "$components/panel/PreviewManager.svelte"; import PreviewManager from "$components/panel/PreviewManager.svelte";
import { downloadService } from "$services/downloadService"; import { downloadService } from "$services/downloadService";
import { konvaStageState } from "$states/konvaStage.svelte"; import { konvaStageState } from "$states/konvaStage.svelte";
import { STATE_DATA } from "$states/persisted.svelte"; import { STATE_DATA } from "$states/persisted.svelte";
import { textsState } from "$states/texts.svelte"; import { textsState } from "$states/texts.svelte";
import { render, screen, waitFor } from "@testing-library/svelte"; import { render, screen, waitFor } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Stage } from "svelte-konva";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("$services/downloadService", () => ({
downloadService: { vi.mock("$services/downloadService", () => ({
downloadAll: vi.fn(), downloadService: {
downloadPanel: vi.fn(), downloadAll: vi.fn(),
}, downloadPanel: vi.fn(),
})); },
}));
vi.mock("./Preview.svelte", () => ({
default: { render: () => ({}) }, vi.mock("./Preview.svelte", () => ({
})); default: { render: () => ({}) },
describe("PreviewManager Integration", () => { }));
beforeEach(() => { describe("PreviewManager Integration", () => {
vi.clearAllMocks(); beforeEach(() => {
textsState[STATE_DATA] = []; vi.clearAllMocks();
}); textsState[STATE_DATA] = [];
});
it("should show empty state by aria-label when no texts", () => {
render(PreviewManager); it("should show empty state by aria-label when no texts", () => {
expect(screen.getByLabelText(/Empty texts info/i)).toBeInTheDocument(); render(PreviewManager);
}); expect(screen.getByLabelText(/Empty texts info/i)).toBeInTheDocument();
});
it("should toggle navigation buttons availability based on texts length", async () => {
textsState[STATE_DATA] = ["1", "2"]; it("should toggle navigation buttons availability based on texts length", async () => {
render(PreviewManager); textsState[STATE_DATA] = ["1", "2"];
render(PreviewManager);
const nextBtn = screen.getByRole("button", { name: /next slide/i });
const prevBtn = screen.getByRole("button", { name: /previous slide/i }); const nextBtn = screen.getByRole("button", { name: /next slide/i });
const prevBtn = screen.getByRole("button", { name: /previous slide/i });
expect(nextBtn).not.toBeDisabled();
expect(prevBtn).toBeDisabled(); expect(nextBtn).not.toBeDisabled();
}); expect(prevBtn).toBeDisabled();
});
it("should send correct data to downloadAll service", async () => {
const user = userEvent.setup(); it("should send correct data to downloadAll service", async () => {
textsState[STATE_DATA] = ["Apple", "Banana"]; const user = userEvent.setup();
render(PreviewManager); textsState[STATE_DATA] = ["Apple", "Banana"];
render(PreviewManager);
await user.click(screen.getByRole("button", { name: /download all/i }));
await user.click(screen.getByRole("button", { name: /download all/i }));
expect(downloadService.downloadAll).toHaveBeenCalledWith(
expect.arrayContaining([ expect(downloadService.downloadAll).toHaveBeenCalledWith(
expect.objectContaining({ filename: "Apple" }), expect.arrayContaining([
expect.objectContaining({ filename: "Banana" }), expect.objectContaining({ filename: "Apple" }),
]), expect.objectContaining({ filename: "Banana" }),
); ]),
}); );
});
it("should call downloadPanel with current active text", async () => {
const user = userEvent.setup(); it("should call downloadPanel with current active text", async () => {
textsState[STATE_DATA] = ["First", "Second"]; const user = userEvent.setup();
konvaStageState.stage = { node: { id: "stage-ref" } } as any; textsState[STATE_DATA] = ["First", "Second"];
konvaStageState.stage = { node: { id: "stage-ref" } } as unknown as Stage;
render(PreviewManager);
render(PreviewManager);
// Переходим на второй слайд
await user.click(screen.getByRole("button", { name: /next slide/i })); // Переходим на второй слайд
await user.click(screen.getByRole("button", { name: /download current/i })); await user.click(screen.getByRole("button", { name: /next slide/i }));
await user.click(screen.getByRole("button", { name: /download current/i }));
expect(downloadService.downloadPanel).toHaveBeenCalledWith(expect.anything(), "Second");
}); expect(downloadService.downloadPanel).toHaveBeenCalledWith(expect.anything(), "Second");
});
it("should return to empty state when texts are removed", async () => {
textsState[STATE_DATA] = ["Temp"]; it("should return to empty state when texts are removed", async () => {
render(PreviewManager); textsState[STATE_DATA] = ["Temp"];
render(PreviewManager);
expect(screen.queryByLabelText(/Empty texts info/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Empty texts info/i)).not.toBeInTheDocument();
textsState[STATE_DATA] = [];
textsState[STATE_DATA] = [];
await waitFor(() => {
expect(screen.getByLabelText(/Empty texts info/i)).toBeInTheDocument(); await waitFor(() => {
}); expect(screen.getByLabelText(/Empty texts info/i)).toBeInTheDocument();
}); });
});
it("should automatically correct current index on items deletion", async () => {
const user = userEvent.setup(); it("should automatically correct current index on items deletion", async () => {
textsState[STATE_DATA] = ["1", "2", "3"]; const user = userEvent.setup();
render(PreviewManager); textsState[STATE_DATA] = ["1", "2", "3"];
render(PreviewManager);
// Уходим на последний слайд
await user.click(screen.getByRole("button", { name: /next slide/i })); // Уходим на последний слайд
await user.click(screen.getByRole("button", { name: /next slide/i })); await user.click(screen.getByRole("button", { name: /next slide/i }));
await user.click(screen.getByRole("button", { name: /next slide/i }));
// Удаляем элементы. Индекс должен упасть с 2 до 0
textsState[STATE_DATA] = ["Only one left"]; // Удаляем элементы. Индекс должен упасть с 2 до 0
textsState[STATE_DATA] = ["Only one left"];
await waitFor(() => {
// Проверяем, что кнопка "Next" заблокировалась (значит мы на последнем/единственном) await waitFor(() => {
expect(screen.getByRole("button", { name: /next slide/i })).toBeDisabled(); // Проверяем, что кнопка "Next" заблокировалась (значит мы на последнем/единственном)
expect(screen.getByRole("button", { name: /previous slide/i })).toBeDisabled(); expect(screen.getByRole("button", { name: /next slide/i })).toBeDisabled();
}); expect(screen.getByRole("button", { name: /previous slide/i })).toBeDisabled();
}); });
}); });
});
+13 -13
View File
@@ -1,13 +1,13 @@
import TextConfig from "$components/text/TextConfig.svelte"; import TextConfig from "$components/text/TextConfig.svelte";
import { cleanup, render } from "@testing-library/svelte"; import { cleanup, render } from "@testing-library/svelte";
import { afterEach, describe, it } from "vitest"; import { afterEach, describe, it } from "vitest";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
}); });
describe("TextConfig.svelte", () => { describe("TextConfig.svelte", () => {
it("should render without crashing", () => { it("should render without crashing", () => {
render(TextConfig); render(TextConfig);
}); });
}); });
@@ -1,36 +1,36 @@
import TextInlineEdit from "$components/text/TextInlineEdit.svelte"; import TextInlineEdit from "$components/text/TextInlineEdit.svelte";
import { cleanup, render, screen } from "@testing-library/svelte"; import { cleanup, render, screen } from "@testing-library/svelte";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
}); });
describe("TextInlineEdit.svelte", () => { describe("TextInlineEdit.svelte", () => {
it("should render with initial text", () => { it("should render with initial text", () => {
render(TextInlineEdit, { render(TextInlineEdit, {
props: { props: {
id: 1, id: 1,
text: "Test text", text: "Test text",
ondelete: () => {}, ondelete: () => {},
}, },
}); });
const input = screen.getByRole("textbox"); const input = screen.getByRole("textbox");
expect(input).toBeInTheDocument(); expect(input).toBeInTheDocument();
expect(input).toHaveValue("Test text"); expect(input).toHaveValue("Test text");
}); });
it("should render with empty text", () => { it("should render with empty text", () => {
render(TextInlineEdit, { render(TextInlineEdit, {
props: { props: {
id: 1, id: 1,
text: "", text: "",
ondelete: () => {}, ondelete: () => {},
}, },
}); });
const input = screen.getByRole("textbox"); const input = screen.getByRole("textbox");
expect(input).toHaveValue(""); expect(input).toHaveValue("");
}); });
}); });
+61 -61
View File
@@ -1,61 +1,61 @@
import TextInput from "$components/text/TextInput.svelte"; import TextInput from "$components/text/TextInput.svelte";
import { render, screen } from "@testing-library/svelte"; import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
describe("TextInput.svelte", () => { describe("TextInput.svelte", () => {
it("should render with initial value", () => { it("should render with initial value", () => {
render(TextInput, { render(TextInput, {
props: { props: {
text: "Test text", text: "Test text",
onenter: vi.fn(), onenter: vi.fn(),
ariaLabel: "Test input", ariaLabel: "Test input",
}, },
}); });
const input = screen.getByRole("textbox", { name: /test input/i }); const input = screen.getByRole("textbox", { name: /test input/i });
expect(input).toBeInTheDocument(); expect(input).toBeInTheDocument();
expect(input).toHaveValue("Test text"); expect(input).toHaveValue("Test text");
}); });
it("should update value on input", async () => { it("should update value on input", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(TextInput, { render(TextInput, {
props: { props: {
text: "Initial", text: "Initial",
onenter: vi.fn(), onenter: vi.fn(),
ariaLabel: "Test input", ariaLabel: "Test input",
}, },
}); });
const input = screen.getByRole("textbox", { name: /test input/i }); const input = screen.getByRole("textbox", { name: /test input/i });
await user.clear(input); await user.clear(input);
await user.type(input, "Updated text"); await user.type(input, "Updated text");
expect(input).toHaveValue("Updated text"); expect(input).toHaveValue("Updated text");
}); });
it("should call onenter only on Enter key and not on others", async () => { it("should call onenter only on Enter key and not on others", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onenterSpy = vi.fn(); const onenterSpy = vi.fn();
render(TextInput, { render(TextInput, {
props: { props: {
text: "test", text: "test",
onenter: onenterSpy, onenter: onenterSpy,
ariaLabel: "Test input", ariaLabel: "Test input",
}, },
}); });
const input = screen.getByRole("textbox", { name: /test input/i }); const input = screen.getByRole("textbox", { name: /test input/i });
await user.type(input, "abc"); await user.type(input, "abc");
expect(onenterSpy).not.toHaveBeenCalled(); expect(onenterSpy).not.toHaveBeenCalled();
await user.keyboard("{Escape}"); await user.keyboard("{Escape}");
expect(onenterSpy).not.toHaveBeenCalled(); expect(onenterSpy).not.toHaveBeenCalled();
await user.type(input, "{Enter}"); await user.type(input, "{Enter}");
expect(onenterSpy).toHaveBeenCalledTimes(1); expect(onenterSpy).toHaveBeenCalledTimes(1);
}); });
}); });
+60 -60
View File
@@ -1,60 +1,60 @@
import TextManager from "$components/text/TextManager.svelte"; import TextManager from "$components/text/TextManager.svelte";
import { textsState } from "$states/texts.svelte"; import { textsState } from "$states/texts.svelte";
import { render, screen } from "@testing-library/svelte"; import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
describe("TextManager", () => { describe("TextManager", () => {
beforeEach(() => { beforeEach(() => {
textsState.clear(); textsState.clear();
}); });
it("should add text to state and clear input on button click", async () => { it("should add text to state and clear input on button click", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(TextManager); render(TextManager);
const input = screen.getByRole("textbox", { name: /input new text/i }); const input = screen.getByRole("textbox", { name: /input new text/i });
const addButton = screen.getByRole("button", { name: /add text/i }); const addButton = screen.getByRole("button", { name: /add text/i });
await user.type(input, "New Note"); await user.type(input, "New Note");
await user.click(addButton); await user.click(addButton);
expect(textsState.texts).toHaveLength(1); expect(textsState.texts).toHaveLength(1);
expect(textsState.texts[0].text).toBe("New Note"); expect(textsState.texts[0].text).toBe("New Note");
expect(input).toHaveValue(""); expect(input).toHaveValue("");
}); });
it("should add text on enter key", async () => { it("should add text on enter key", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(TextManager); render(TextManager);
const input = screen.getByRole("textbox", { name: /input new text/i }); const input = screen.getByRole("textbox", { name: /input new text/i });
await user.type(input, "Enter Note{Enter}"); await user.type(input, "Enter Note{Enter}");
expect(textsState.texts).toHaveLength(1); expect(textsState.texts).toHaveLength(1);
expect(textsState.texts[0].text).toBe("Enter Note"); expect(textsState.texts[0].text).toBe("Enter Note");
}); });
it("should display all items from state", async () => { it("should display all items from state", async () => {
textsState.addText("First"); textsState.addText("First");
textsState.addText("Second"); textsState.addText("Second");
render(TextManager); render(TextManager);
const items = screen.getAllByRole("listitem"); const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(2); expect(items).toHaveLength(2);
}); });
it("should remove text from state when delete button is clicked", async () => { it("should remove text from state when delete button is clicked", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
textsState.addText("To be deleted"); textsState.addText("To be deleted");
render(TextManager); render(TextManager);
const deleteBtn = screen.getByRole("button", { name: /delete/i }); const deleteBtn = screen.getByRole("button", { name: /delete/i });
await user.click(deleteBtn); await user.click(deleteBtn);
expect(textsState.texts).toHaveLength(0); expect(textsState.texts).toHaveLength(0);
}); });
}); });
+43 -44
View File
@@ -1,44 +1,43 @@
import { TextAlign } from "$lib/constants"; import { TextAlign } from "$lib/constants";
import { render, screen } from "@testing-library/svelte"; import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import AlignmentTest from "./AlignmentTest.svelte"; import AlignmentTest from "./AlignmentTest.svelte";
describe("Alignment.svelte", () => { describe("Alignment.svelte", () => {
it("should update state for every button by clicking in sequence", async () => { it("should update state for every button by clicking in sequence", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(AlignmentTest); render(AlignmentTest);
const buttons = screen.getAllByRole("radio"); const buttons = screen.getAllByRole("radio");
const stateDisplay = screen.getByTestId("align-value"); const stateDisplay = screen.getByTestId("align-value");
const allButtonsToClick = [...buttons, buttons[0]]; const allButtonsToClick = [...buttons, buttons[0]];
for (const button of allButtonsToClick) { for (const button of allButtonsToClick) {
const label = button.getAttribute("aria-label")?.toLowerCase() || ""; const label = button.getAttribute("aria-label")?.toLowerCase() || "";
await user.click(button); await user.click(button);
const finalValue = stateDisplay.textContent?.toLowerCase() || ""; const finalValue = stateDisplay.textContent?.toLowerCase() || "";
expect(label).toContain(finalValue); expect(label).toContain(finalValue);
} }
}); });
it("should have initial state from props", () => { it("should have initial state from props", () => {
render(AlignmentTest, { props: { align: TextAlign.RIGHT } }); render(AlignmentTest, { props: { align: TextAlign.RIGHT } });
expect(screen.getByTestId("align-value").textContent).toBe(TextAlign.RIGHT); expect(screen.getByTestId("align-value").textContent).toBe(TextAlign.RIGHT);
}); });
it("should sync with initial state and change state", async () => { it("should sync with initial state and change state", async () => {
const user = userEvent.setup(); const { rerender } = render(AlignmentTest, { props: { align: TextAlign.RIGHT } });
const { rerender } = render(AlignmentTest, { props: { align: TextAlign.RIGHT } }); const stateDisplay = screen.getByTestId("align-value");
const stateDisplay = screen.getByTestId("align-value");
expect(stateDisplay.textContent).toBe(TextAlign.RIGHT);
expect(stateDisplay.textContent).toBe(TextAlign.RIGHT);
await rerender({ align: TextAlign.CENTER });
await rerender({ align: TextAlign.CENTER });
const centerBtn = screen.getByRole("radio", { name: new RegExp(TextAlign.CENTER, "i") });
const centerBtn = screen.getByRole("radio", { name: new RegExp(TextAlign.CENTER, "i") }); expect(centerBtn).toBeChecked();
expect(centerBtn).toBeChecked(); });
}); });
});
+11 -11
View File
@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import Alignment from "$components/ui/Alignment.svelte"; import Alignment from "$components/ui/Alignment.svelte";
import { TextAlign } from "$lib/constants"; import { TextAlign } from "$lib/constants";
let { align = TextAlign.LEFT } = $props(); let { align = TextAlign.LEFT } = $props();
</script> </script>
<Alignment bind:align /> <Alignment bind:align />
<div data-testid="align-value">{align}</div> <div data-testid="align-value">{align}</div>
+43 -43
View File
@@ -1,43 +1,43 @@
import Badge from "$components/ui/Badge.svelte"; import Badge from "$components/ui/Badge.svelte";
import { render, screen } from "@testing-library/svelte"; import { render, screen } from "@testing-library/svelte";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
describe("Badge.svelte", () => { describe("Badge.svelte", () => {
it("should render with string text", () => { it("should render with string text", () => {
render(Badge, { render(Badge, {
props: { props: {
text: "Test Badge", text: "Test Badge",
}, },
}); });
expect(screen.getByText("Test Badge")).toBeInTheDocument(); expect(screen.getByText("Test Badge")).toBeInTheDocument();
}); });
it("should render with number text", () => { it("should render with number text", () => {
render(Badge, { render(Badge, {
props: { props: {
text: 42, text: 42,
}, },
}); });
expect(screen.getByText("42")).toBeInTheDocument(); expect(screen.getByText("42")).toBeInTheDocument();
}); });
it("should render with empty string", () => { it("should render with empty string", () => {
render(Badge, { render(Badge, {
props: { props: {
text: "", text: "",
}, },
}); });
}); });
it("should render with zero", () => { it("should render with zero", () => {
render(Badge, { render(Badge, {
props: { props: {
text: 0, text: 0,
}, },
}); });
expect(screen.getByText("0")).toBeInTheDocument(); expect(screen.getByText("0")).toBeInTheDocument();
}); });
}); });
+95 -95
View File
@@ -1,95 +1,95 @@
import Button from "$components/ui/Button.svelte"; import Button from "$components/ui/Button.svelte";
import { render, screen } from "@testing-library/svelte"; import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import MockIcon from "./MockIcon.svelte"; import MockIcon from "./MockIcon.svelte";
describe("Button.svelte", () => { describe("Button.svelte", () => {
it("should render with icon and label", () => { it("should render with icon and label", () => {
render(Button, { render(Button, {
props: { props: {
icon: MockIcon, icon: MockIcon,
label: "Test Button", label: "Test Button",
ariaLabel: "Test button", ariaLabel: "Test button",
}, },
}); });
expect(screen.getByText("Test Button")).toBeInTheDocument(); expect(screen.getByText("Test Button")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /test button/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /test button/i })).toBeInTheDocument();
}); });
it("should render with icon only", () => { it("should render with icon only", () => {
render(Button, { render(Button, {
props: { props: {
icon: MockIcon, icon: MockIcon,
ariaLabel: "Test button", ariaLabel: "Test button",
}, },
}); });
const button = screen.getByRole("button", { name: /test button/i }); const button = screen.getByRole("button", { name: /test button/i });
expect(button).toBeInTheDocument(); expect(button).toBeInTheDocument();
expect(button.textContent.trim()).toBe(""); expect(button.textContent.trim()).toBe("");
}); });
it("should call onclick handler", async () => { it("should call onclick handler", async () => {
const onclick = vi.fn(); const onclick = vi.fn();
const user = userEvent.setup(); const user = userEvent.setup();
render(Button, { render(Button, {
props: { props: {
icon: MockIcon, icon: MockIcon,
label: "Click me", label: "Click me",
onclick, onclick,
ariaLabel: "Test button", ariaLabel: "Test button",
}, },
}); });
const button = screen.getByRole("button", { name: /test button/i }); const button = screen.getByRole("button", { name: /test button/i });
await user.click(button); await user.click(button);
expect(onclick).toHaveBeenCalledTimes(1); expect(onclick).toHaveBeenCalledTimes(1);
}); });
it("should not throw error when clicked without onclick prop", async () => { it("should not throw error when clicked without onclick prop", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(Button, { render(Button, {
props: { props: {
icon: MockIcon, icon: MockIcon,
label: "Click me", label: "Click me",
ariaLabel: "Test button", ariaLabel: "Test button",
}, },
}); });
const button = screen.getByRole("button", { name: /test button/i }); const button = screen.getByRole("button", { name: /test button/i });
expect(() => user.click(button)).not.toThrow(); expect(() => user.click(button)).not.toThrow();
}); });
it("should be disabled when disabled prop is true", () => { it("should be disabled when disabled prop is true", () => {
render(Button, { render(Button, {
props: { props: {
icon: MockIcon, icon: MockIcon,
label: "Disabled", label: "Disabled",
disabled: true, disabled: true,
ariaLabel: "Test button", ariaLabel: "Test button",
}, },
}); });
const button = screen.getByRole("button", { name: /test button/i }); const button = screen.getByRole("button", { name: /test button/i });
expect(button).toBeDisabled(); expect(button).toBeDisabled();
}); });
it("should not be disabled by default", () => { it("should not be disabled by default", () => {
render(Button, { render(Button, {
props: { props: {
icon: MockIcon, icon: MockIcon,
label: "Enabled", label: "Enabled",
ariaLabel: "Test button", ariaLabel: "Test button",
}, },
}); });
const button = screen.getByRole("button", { name: /test button/i }); const button = screen.getByRole("button", { name: /test button/i });
expect(button).not.toBeDisabled(); expect(button).not.toBeDisabled();
}); });
}); });
+13 -13
View File
@@ -1,13 +1,13 @@
import ColorPicker from "$components/ui/ColorPicker.svelte"; import ColorPicker from "$components/ui/ColorPicker.svelte";
import { render } from "@testing-library/svelte"; import { render } from "@testing-library/svelte";
import { describe, it } from "vitest"; import { describe, it } from "vitest";
describe("ColorPicker.svelte", () => { describe("ColorPicker.svelte", () => {
it("should render without crashing", () => { it("should render without crashing", () => {
render(ColorPicker, { render(ColorPicker, {
props: { props: {
value: "#ffffff", value: "#ffffff",
}, },
}); });
}); });
}); });
+3 -3
View File
@@ -1,3 +1,3 @@
<svg data-testid="mock-icon" viewBox="0 0 24 24"> <svg data-testid="mock-icon" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 96 B

After

Width:  |  Height:  |  Size: 93 B

+21 -21
View File
@@ -1,21 +1,21 @@
import RangeSlider from "$components/ui/RangeSlider.svelte"; import RangeSlider from "$components/ui/RangeSlider.svelte";
import { fireEvent, render, screen } from "@testing-library/svelte"; import { fireEvent, render, screen } from "@testing-library/svelte";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
describe("RangeSlider.svelte", () => { describe("RangeSlider.svelte", () => {
it("should call onchange handler", async () => { it("should call onchange handler", async () => {
const onchange = vi.fn(); const onchange = vi.fn();
render(RangeSlider, { render(RangeSlider, {
props: { props: {
value: 50, value: 50,
onchange, onchange,
}, },
}); });
const slider = screen.getByRole("slider"); const slider = screen.getByRole("slider");
await fireEvent.change(slider, { target: { value: "75" } }); await fireEvent.change(slider, { target: { value: "75" } });
expect(onchange).toHaveBeenCalledTimes(1); expect(onchange).toHaveBeenCalledTimes(1);
}); });
}); });
+13 -13
View File
@@ -1,13 +1,13 @@
import SelectFont from "$components/ui/SelectFont.svelte"; import SelectFont from "$components/ui/SelectFont.svelte";
import { render } from "@testing-library/svelte"; import { render } from "@testing-library/svelte";
import { describe, it } from "vitest"; import { describe, it } from "vitest";
describe("SelectFont.svelte", () => { describe("SelectFont.svelte", () => {
it("should render without crashing", () => { it("should render without crashing", () => {
render(SelectFont, { render(SelectFont, {
props: { props: {
value: "Arial", value: "Arial",
}, },
}); });
}); });
}); });
+70 -68
View File
@@ -1,68 +1,70 @@
import * as Constants from "$lib/constants"; import * as Constants from "$lib/constants";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
describe("Application Constants Logic", () => { describe("Application Constants Logic", () => {
describe("Typography & Panel Constraints", () => { describe("Typography & Panel Constraints", () => {
it("should have default values within allowed boundaries", () => { it("should have default values within allowed boundaries", () => {
const { TYPOGRAPHY, PANEL_SETTINGS } = Constants; const { TYPOGRAPHY, PANEL_SETTINGS } = Constants;
expect(TYPOGRAPHY.FONT_SIZE_DEFAULT).toBeGreaterThanOrEqual(TYPOGRAPHY.FONT_SIZE_MIN); expect(TYPOGRAPHY.FONT_SIZE_DEFAULT).toBeGreaterThanOrEqual(TYPOGRAPHY.FONT_SIZE_MIN);
expect(TYPOGRAPHY.FONT_SIZE_DEFAULT).toBeLessThanOrEqual(TYPOGRAPHY.FONT_SIZE_MAX); expect(TYPOGRAPHY.FONT_SIZE_DEFAULT).toBeLessThanOrEqual(TYPOGRAPHY.FONT_SIZE_MAX);
expect(TYPOGRAPHY.FONT_FAMILIES).toContain(TYPOGRAPHY.FONT_FAMILY_DEFAULT); expect(TYPOGRAPHY.FONT_FAMILIES).toContain(TYPOGRAPHY.FONT_FAMILY_DEFAULT);
expect(PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT).toBeLessThanOrEqual(PANEL_SETTINGS.PANEL_HEIGHT_MAX); expect(PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT).toBeLessThanOrEqual(
PANEL_SETTINGS.PANEL_HEIGHT_MAX,
expect(TYPOGRAPHY.VERTICAL_OFFSET_MAX).toBeGreaterThan(TYPOGRAPHY.VERTICAL_OFFSET_MIN); );
});
expect(TYPOGRAPHY.VERTICAL_OFFSET_MAX).toBeGreaterThan(TYPOGRAPHY.VERTICAL_OFFSET_MIN);
it("should not have duplicate values in lists", () => { });
const { TYPOGRAPHY, IMAGE_SETTINGS } = Constants;
it("should not have duplicate values in lists", () => {
const uniqueFonts = new Set(TYPOGRAPHY.FONT_FAMILIES); const { TYPOGRAPHY, IMAGE_SETTINGS } = Constants;
expect(uniqueFonts.size).toBe(TYPOGRAPHY.FONT_FAMILIES.length);
const uniqueFonts = new Set(TYPOGRAPHY.FONT_FAMILIES);
const uniqueFormats = new Set(IMAGE_SETTINGS.SUPPORTED_FORMATS); expect(uniqueFonts.size).toBe(TYPOGRAPHY.FONT_FAMILIES.length);
expect(uniqueFormats.size).toBe(IMAGE_SETTINGS.SUPPORTED_FORMATS.length);
}); const uniqueFormats = new Set(IMAGE_SETTINGS.SUPPORTED_FORMATS);
}); expect(uniqueFormats.size).toBe(IMAGE_SETTINGS.SUPPORTED_FORMATS.length);
});
describe("Integrity & Formats", () => { });
it("should have valid format patterns for colors and mime-types", () => {
const { TYPOGRAPHY, IMAGE_SETTINGS } = Constants; describe("Integrity & Formats", () => {
it("should have valid format patterns for colors and mime-types", () => {
expect(TYPOGRAPHY.TEXT_COLOR_DEFAULT).toMatch(/^#[0-9a-f]{6}$/i); const { TYPOGRAPHY, IMAGE_SETTINGS } = Constants;
IMAGE_SETTINGS.SUPPORTED_FORMATS.forEach((format) => { expect(TYPOGRAPHY.TEXT_COLOR_DEFAULT).toMatch(/^#[0-9a-f]{6}$/i);
expect(format).toMatch(/^image\/(jpeg|jpg|png|webp|gif)$/);
}); IMAGE_SETTINGS.SUPPORTED_FORMATS.forEach((format) => {
}); expect(format).toMatch(/^image\/(jpeg|jpg|png|webp|gif)$/);
}); });
});
describe("Environment Specifics", () => { });
it("should set transition duration to 0 in test mode", () => {
expect(Constants.TRANSITION_DURATION).toBe(0); describe("Environment Specifics", () => {
}); it("should set transition duration to 0 in test mode", () => {
}); expect(Constants.TRANSITION_DURATION).toBe(0);
});
describe("Global Contract (Snapshot)", () => { });
it("should match the previous configuration snapshot", () => {
expect(Constants).toMatchSnapshot(); describe("Global Contract (Snapshot)", () => {
}); it("should match the previous configuration snapshot", () => {
}); expect(Constants).toMatchSnapshot();
});
describe("Assets Existence", () => { });
it("should verify that the default background image exists", () => {
const { DEFAULT_BACKGROUND_IMAGE } = Constants.PANEL_SETTINGS; describe("Assets Existence", () => {
it("should verify that the default background image exists", () => {
const relativePath = DEFAULT_BACKGROUND_IMAGE.replace(/^\.\//, ""); const { DEFAULT_BACKGROUND_IMAGE } = Constants.PANEL_SETTINGS;
const fullPath = path.resolve(process.cwd(), "static", relativePath);
const relativePath = DEFAULT_BACKGROUND_IMAGE.replace(/^\.\//, "");
const exists = fs.existsSync(fullPath); const fullPath = path.resolve(process.cwd(), "static", relativePath);
expect(exists, `Image not found at: ${fullPath}`).toBe(true); const exists = fs.existsSync(fullPath);
});
}); expect(exists, `Image not found at: ${fullPath}`).toBe(true);
}); });
});
});
+207 -207
View File
@@ -1,207 +1,207 @@
import { AppError, CanvasError, ImageError, StorageError, TextError } from "$lib/error.types"; import { AppError, CanvasError, ImageError, StorageError, TextError } from "$lib/error.types";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
describe("error.types", () => { describe("error.types", () => {
describe("AppError", () => { describe("AppError", () => {
it("should create AppError with message and code", () => { it("should create AppError with message and code", () => {
const error = new AppError("Test error message", "TEST_CODE"); const error = new AppError("Test error message", "TEST_CODE");
expect(error).toBeInstanceOf(Error); expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(AppError); expect(error).toBeInstanceOf(AppError);
expect(error.name).toBe("AppError"); expect(error.name).toBe("AppError");
expect(error.message).toBe("Test error message"); expect(error.message).toBe("Test error message");
expect(error.code).toBe("TEST_CODE"); expect(error.code).toBe("TEST_CODE");
expect(error.details).toBeUndefined(); expect(error.details).toBeUndefined();
}); });
it("should create AppError with message, code and details", () => { it("should create AppError with message, code and details", () => {
const details = { userId: 123, action: "test" }; const details = { userId: 123, action: "test" };
const error = new AppError("Test error message", "TEST_CODE", details); const error = new AppError("Test error message", "TEST_CODE", details);
expect(error.details).toEqual(details); expect(error.details).toEqual(details);
expect(error.message).toBe("Test error message"); expect(error.message).toBe("Test error message");
expect(error.code).toBe("TEST_CODE"); expect(error.code).toBe("TEST_CODE");
}); });
it("should have stack trace", () => { it("should have stack trace", () => {
const error = new AppError("Test error", "TEST_CODE"); const error = new AppError("Test error", "TEST_CODE");
expect(error.stack).toBeDefined(); expect(error.stack).toBeDefined();
expect(typeof error.stack).toBe("string"); expect(typeof error.stack).toBe("string");
}); });
it("should be throwable and catchable", () => { it("should be throwable and catchable", () => {
expect(() => { expect(() => {
throw new AppError("Test error", "TEST_CODE"); throw new AppError("Test error", "TEST_CODE");
}).toThrow(AppError); }).toThrow(AppError);
}); });
it("should be catchable as Error", () => { it("should be catchable as Error", () => {
expect(() => { expect(() => {
throw new AppError("Test error", "TEST_CODE"); throw new AppError("Test error", "TEST_CODE");
}).toThrow(Error); }).toThrow(Error);
}); });
}); });
describe("ImageError", () => { describe("ImageError", () => {
it("should create ImageError with message", () => { it("should create ImageError with message", () => {
const error = new ImageError("Image loading failed"); const error = new ImageError("Image loading failed");
expect(error).toBeInstanceOf(Error); expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(AppError); expect(error).toBeInstanceOf(AppError);
expect(error).toBeInstanceOf(ImageError); expect(error).toBeInstanceOf(ImageError);
expect(error.name).toBe("AppError"); expect(error.name).toBe("AppError");
expect(error.message).toBe("Image loading failed"); expect(error.message).toBe("Image loading failed");
expect(error.code).toBe("IMAGE_ERROR"); expect(error.code).toBe("IMAGE_ERROR");
}); });
it("should have correct error code", () => { it("should have correct error code", () => {
const error = new ImageError("Test"); const error = new ImageError("Test");
expect(error.code).toBe("IMAGE_ERROR"); expect(error.code).toBe("IMAGE_ERROR");
}); });
it("should be identifiable as ImageError", () => { it("should be identifiable as ImageError", () => {
const error = new ImageError("Test"); const error = new ImageError("Test");
expect(error instanceof ImageError).toBe(true); expect(error instanceof ImageError).toBe(true);
}); });
}); });
describe("TextError", () => { describe("TextError", () => {
it("should create TextError with message", () => { it("should create TextError with message", () => {
const error = new TextError("Text validation failed"); const error = new TextError("Text validation failed");
expect(error).toBeInstanceOf(Error); expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(AppError); expect(error).toBeInstanceOf(AppError);
expect(error).toBeInstanceOf(TextError); expect(error).toBeInstanceOf(TextError);
expect(error.name).toBe("AppError"); expect(error.name).toBe("AppError");
expect(error.message).toBe("Text validation failed"); expect(error.message).toBe("Text validation failed");
expect(error.code).toBe("TEXT_ERROR"); expect(error.code).toBe("TEXT_ERROR");
}); });
it("should have correct error code", () => { it("should have correct error code", () => {
const error = new TextError("Test"); const error = new TextError("Test");
expect(error.code).toBe("TEXT_ERROR"); expect(error.code).toBe("TEXT_ERROR");
}); });
it("should be identifiable as TextError", () => { it("should be identifiable as TextError", () => {
const error = new TextError("Test"); const error = new TextError("Test");
expect(error instanceof TextError).toBe(true); expect(error instanceof TextError).toBe(true);
}); });
}); });
describe("CanvasError", () => { describe("CanvasError", () => {
it("should create CanvasError with message", () => { it("should create CanvasError with message", () => {
const error = new CanvasError("Canvas rendering failed"); const error = new CanvasError("Canvas rendering failed");
expect(error).toBeInstanceOf(Error); expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(AppError); expect(error).toBeInstanceOf(AppError);
expect(error).toBeInstanceOf(CanvasError); expect(error).toBeInstanceOf(CanvasError);
expect(error.name).toBe("AppError"); expect(error.name).toBe("AppError");
expect(error.message).toBe("Canvas rendering failed"); expect(error.message).toBe("Canvas rendering failed");
expect(error.code).toBe("CANVAS_ERROR"); expect(error.code).toBe("CANVAS_ERROR");
}); });
it("should have correct error code", () => { it("should have correct error code", () => {
const error = new CanvasError("Test"); const error = new CanvasError("Test");
expect(error.code).toBe("CANVAS_ERROR"); expect(error.code).toBe("CANVAS_ERROR");
}); });
it("should be identifiable as CanvasError", () => { it("should be identifiable as CanvasError", () => {
const error = new CanvasError("Test"); const error = new CanvasError("Test");
expect(error instanceof CanvasError).toBe(true); expect(error instanceof CanvasError).toBe(true);
}); });
}); });
describe("StorageError", () => { describe("StorageError", () => {
it("should create StorageError with message", () => { it("should create StorageError with message", () => {
const error = new StorageError("Storage operation failed"); const error = new StorageError("Storage operation failed");
expect(error).toBeInstanceOf(Error); expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(AppError); expect(error).toBeInstanceOf(AppError);
expect(error).toBeInstanceOf(StorageError); expect(error).toBeInstanceOf(StorageError);
expect(error.name).toBe("AppError"); expect(error.name).toBe("AppError");
expect(error.message).toBe("Storage operation failed"); expect(error.message).toBe("Storage operation failed");
expect(error.code).toBe("STORAGE_ERROR"); expect(error.code).toBe("STORAGE_ERROR");
}); });
it("should have correct error code", () => { it("should have correct error code", () => {
const error = new StorageError("Test"); const error = new StorageError("Test");
expect(error.code).toBe("STORAGE_ERROR"); expect(error.code).toBe("STORAGE_ERROR");
}); });
it("should be identifiable as StorageError", () => { it("should be identifiable as StorageError", () => {
const error = new StorageError("Test"); const error = new StorageError("Test");
expect(error instanceof StorageError).toBe(true); expect(error instanceof StorageError).toBe(true);
}); });
}); });
describe("ErrorType union", () => { describe("ErrorType union", () => {
it("should accept all error types", () => { it("should accept all error types", () => {
const errors: Array<AppError | ImageError | TextError | CanvasError | StorageError> = [ const errors: Array<AppError | ImageError | TextError | CanvasError | StorageError> = [
new AppError("Test", "TEST"), new AppError("Test", "TEST"),
new ImageError("Test"), new ImageError("Test"),
new TextError("Test"), new TextError("Test"),
new CanvasError("Test"), new CanvasError("Test"),
new StorageError("Test"), new StorageError("Test"),
]; ];
errors.forEach((error) => { errors.forEach((error) => {
expect(error).toBeInstanceOf(AppError); expect(error).toBeInstanceOf(AppError);
expect(error).toBeInstanceOf(Error); expect(error).toBeInstanceOf(Error);
}); });
}); });
it("should allow type narrowing with instanceof", () => { it("should allow type narrowing with instanceof", () => {
const errors: Array<AppError | ImageError | TextError | CanvasError | StorageError> = [ const errors: Array<AppError | ImageError | TextError | CanvasError | StorageError> = [
new ImageError("Test"), new ImageError("Test"),
new TextError("Test"), new TextError("Test"),
]; ];
const imageErrors = errors.filter((e) => e instanceof ImageError); const imageErrors = errors.filter((e) => e instanceof ImageError);
const textErrors = errors.filter((e) => e instanceof TextError); const textErrors = errors.filter((e) => e instanceof TextError);
expect(imageErrors).toHaveLength(1); expect(imageErrors).toHaveLength(1);
expect(textErrors).toHaveLength(1); expect(textErrors).toHaveLength(1);
}); });
}); });
describe("Error handling patterns", () => { describe("Error handling patterns", () => {
it("should handle errors in try-catch blocks", () => { it("should handle errors in try-catch blocks", () => {
let caughtError: AppError | null = null; let caughtError: AppError | null = null;
try { try {
throw new ImageError("Image failed to load"); throw new ImageError("Image failed to load");
} catch (error) { } catch (error) {
if (error instanceof AppError) { if (error instanceof AppError) {
caughtError = error; caughtError = error;
} }
} }
expect(caughtError).not.toBeNull(); expect(caughtError).not.toBeNull();
expect(caughtError?.code).toBe("IMAGE_ERROR"); expect(caughtError?.code).toBe("IMAGE_ERROR");
}); });
it("should preserve error details through error handling", () => { it("should preserve error details through error handling", () => {
const originalError = new AppError("Test", "TEST", { id: 123 }); const originalError = new AppError("Test", "TEST", { id: 123 });
let caughtError: AppError | null = null; let caughtError: AppError | null = null;
try { try {
throw originalError; throw originalError;
} catch (error) { } catch (error) {
if (error instanceof AppError) { if (error instanceof AppError) {
caughtError = error; caughtError = error;
} }
} }
expect(caughtError?.details).toEqual({ id: 123 }); expect(caughtError?.details).toEqual({ id: 123 });
}); });
}); });
}); });
+155 -155
View File
@@ -1,155 +1,155 @@
import { AppError, CanvasError, ImageError, StorageError, TextError } from "$lib/error.types"; import { AppError, CanvasError, ImageError, StorageError, TextError } from "$lib/error.types";
import { createError, formatError, logError } from "$lib/utils/errorUtils"; import { createError, formatError, logError } from "$lib/utils/errorUtils";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
describe("errorUtils", () => { describe("errorUtils", () => {
describe("formatError", () => { describe("formatError", () => {
it("should format AppError", () => { it("should format AppError", () => {
const error = new AppError("Test error", "TEST_CODE"); const error = new AppError("Test error", "TEST_CODE");
const result = formatError(error, "Default message"); const result = formatError(error, "Default message");
expect(result).toBe("Default message: Test error"); expect(result).toBe("Default message: Test error");
}); });
it("should format Error", () => { it("should format Error", () => {
const error = new Error("Test error"); const error = new Error("Test error");
const result = formatError(error, "Default message"); const result = formatError(error, "Default message");
expect(result).toBe("Default message: Test error"); expect(result).toBe("Default message: Test error");
}); });
it("should format string error", () => { it("should format string error", () => {
const error = "String error message"; const error = "String error message";
const result = formatError(error, "Default message"); const result = formatError(error, "Default message");
expect(result).toBe("Default message: String error message"); expect(result).toBe("Default message: String error message");
}); });
it("should format unknown error", () => { it("should format unknown error", () => {
const error = { custom: "object" }; const error = { custom: "object" };
const result = formatError(error, "Default message"); const result = formatError(error, "Default message");
expect(result).toBe("Default message: Произошла неизвестная ошибка"); expect(result).toBe("Default message: Произошла неизвестная ошибка");
}); });
it("should use default message when not provided", () => { it("should use default message when not provided", () => {
const error = new Error("Test error"); const error = new Error("Test error");
const result = formatError(error); const result = formatError(error);
expect(result).toBe("Произошла ошибка: Test error"); expect(result).toBe("Произошла ошибка: Test error");
}); });
it("should format ImageError", () => { it("should format ImageError", () => {
const error = new ImageError("Image failed"); const error = new ImageError("Image failed");
const result = formatError(error, "Default message"); const result = formatError(error, "Default message");
expect(result).toBe("Default message: Image failed"); expect(result).toBe("Default message: Image failed");
}); });
it("should format TextError", () => { it("should format TextError", () => {
const error = new TextError("Text failed"); const error = new TextError("Text failed");
const result = formatError(error, "Default message"); const result = formatError(error, "Default message");
expect(result).toBe("Default message: Text failed"); expect(result).toBe("Default message: Text failed");
}); });
it("should format CanvasError", () => { it("should format CanvasError", () => {
const error = new CanvasError("Canvas failed"); const error = new CanvasError("Canvas failed");
const result = formatError(error, "Default message"); const result = formatError(error, "Default message");
expect(result).toBe("Default message: Canvas failed"); expect(result).toBe("Default message: Canvas failed");
}); });
it("should format StorageError", () => { it("should format StorageError", () => {
const error = new StorageError("Storage failed"); const error = new StorageError("Storage failed");
const result = formatError(error, "Default message"); const result = formatError(error, "Default message");
expect(result).toBe("Default message: Storage failed"); expect(result).toBe("Default message: Storage failed");
}); });
}); });
describe("createError", () => { describe("createError", () => {
it("should create AppError with message and code", () => { it("should create AppError with message and code", () => {
const error = createError("Test error", "TEST_CODE"); const error = createError("Test error", "TEST_CODE");
expect(error).toBeInstanceOf(AppError); expect(error).toBeInstanceOf(AppError);
expect(error.message).toBe("Test error"); expect(error.message).toBe("Test error");
expect(error.code).toBe("TEST_CODE"); expect(error.code).toBe("TEST_CODE");
expect(error.details).toBeUndefined(); expect(error.details).toBeUndefined();
}); });
it("should create AppError with message, code and details", () => { it("should create AppError with message, code and details", () => {
const details = { userId: 123, action: "test" }; const details = { userId: 123, action: "test" };
const error = createError("Test error", "TEST_CODE", details); const error = createError("Test error", "TEST_CODE", details);
expect(error.details).toEqual(details); expect(error.details).toEqual(details);
expect(error.message).toBe("Test error"); expect(error.message).toBe("Test error");
expect(error.code).toBe("TEST_CODE"); expect(error.code).toBe("TEST_CODE");
}); });
}); });
describe("logError", () => { describe("logError", () => {
it("should log Error with stack", () => { it("should log Error with stack", () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const error = new Error("Test error"); const error = new Error("Test error");
logError(error, "Test context"); logError(error, "Test context");
expect(consoleSpy).toHaveBeenCalledWith("Error occurred:", { expect(consoleSpy).toHaveBeenCalledWith("Error occurred:", {
error, error,
context: "Test context", context: "Test context",
timestamp: expect.any(String), timestamp: expect.any(String),
stack: expect.any(String), stack: expect.any(String),
}); });
consoleSpy.mockRestore(); consoleSpy.mockRestore();
}); });
it("should log AppError with stack", () => { it("should log AppError with stack", () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const error = new AppError("Test error", "TEST_CODE"); const error = new AppError("Test error", "TEST_CODE");
logError(error, "Test context"); logError(error, "Test context");
expect(consoleSpy).toHaveBeenCalledWith("Error occurred:", { expect(consoleSpy).toHaveBeenCalledWith("Error occurred:", {
error, error,
context: "Test context", context: "Test context",
timestamp: expect.any(String), timestamp: expect.any(String),
stack: expect.any(String), stack: expect.any(String),
}); });
consoleSpy.mockRestore(); consoleSpy.mockRestore();
}); });
it("should log string error without stack", () => { it("should log string error without stack", () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
logError("String error", "Test context"); logError("String error", "Test context");
expect(consoleSpy).toHaveBeenCalledWith("Error occurred:", { expect(consoleSpy).toHaveBeenCalledWith("Error occurred:", {
error: "String error", error: "String error",
context: "Test context", context: "Test context",
timestamp: expect.any(String), timestamp: expect.any(String),
stack: undefined, stack: undefined,
}); });
consoleSpy.mockRestore(); consoleSpy.mockRestore();
}); });
it("should log error without context", () => { it("should log error without context", () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const error = new Error("Test error"); const error = new Error("Test error");
logError(error); logError(error);
expect(consoleSpy).toHaveBeenCalledWith("Error occurred:", { expect(consoleSpy).toHaveBeenCalledWith("Error occurred:", {
error, error,
context: undefined, context: undefined,
timestamp: expect.any(String), timestamp: expect.any(String),
stack: expect.any(String), stack: expect.any(String),
}); });
consoleSpy.mockRestore(); consoleSpy.mockRestore();
}); });
}); });
}); });
+7 -7
View File
@@ -1,7 +1,7 @@
<script> <script>
import Layout from "$routes/+layout.svelte"; import Layout from "$routes/+layout.svelte";
</script> </script>
<Layout> <Layout>
<span data-testid="test-child">Hello</span> <span data-testid="test-child">Hello</span>
</Layout> </Layout>
+5 -5
View File
@@ -1,5 +1,5 @@
<script> <script>
import Page from "$routes/+page.svelte"; import Page from "$routes/+page.svelte";
</script> </script>
<Page /> <Page />
+12 -12
View File
@@ -1,12 +1,12 @@
import { prerender, ssr } from "$routes/+layout"; import { prerender, ssr } from "$routes/+layout";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
describe("+layout.ts", () => { describe("+layout.ts", () => {
it("should export ssr as false", () => { it("should export ssr as false", () => {
expect(ssr).toBe(false); expect(ssr).toBe(false);
}); });
it("should export prerender as true", () => { it("should export prerender as true", () => {
expect(prerender).toBe(true); expect(prerender).toBe(true);
}); });
}); });
+39 -39
View File
@@ -1,39 +1,39 @@
import { themeState } from "$states/theme.svelte"; import { themeState } from "$states/theme.svelte";
import { render, screen } from "@testing-library/svelte"; import { render, screen } from "@testing-library/svelte";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import LayoutTest from "./LayoutTest.svelte"; import LayoutTest from "./LayoutTest.svelte";
describe("+layout.svelte", () => { describe("+layout.svelte", () => {
beforeEach(() => { beforeEach(() => {
themeState.current = "dark"; themeState.current = "dark";
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("should apply dark theme", () => { it("should apply dark theme", () => {
themeState.current = "dark"; themeState.current = "dark";
expect(themeState.current).toBe("dark"); expect(themeState.current).toBe("dark");
}); });
it("should apply light theme", () => { it("should apply light theme", () => {
themeState.current = "light"; themeState.current = "light";
expect(themeState.current).toBe("light"); expect(themeState.current).toBe("light");
}); });
it("should toggle theme", () => { it("should toggle theme", () => {
themeState.current = "dark"; themeState.current = "dark";
themeState.toggle(); themeState.toggle();
expect(themeState.current).toBe("light"); expect(themeState.current).toBe("light");
}); });
}); });
describe("Layout Component Coverage", () => { describe("Layout Component Coverage", () => {
it("should render children snippet and initialize props", () => { it("should render children snippet and initialize props", () => {
render(LayoutTest); render(LayoutTest);
expect(screen.getByTestId("test-child")).toBeInTheDocument(); expect(screen.getByTestId("test-child")).toBeInTheDocument();
expect(screen.getByRole("banner")).toBeInTheDocument(); expect(screen.getByRole("banner")).toBeInTheDocument();
}); });
}); });
+10 -10
View File
@@ -1,10 +1,10 @@
import { render, screen } from "@testing-library/svelte"; import { render, screen } from "@testing-library/svelte";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import PageTest from "./PageTest.svelte"; import PageTest from "./PageTest.svelte";
describe("+page.svelte", () => { describe("+page.svelte", () => {
it("should render without crashing", () => { it("should render without crashing", () => {
render(PageTest); render(PageTest);
expect(screen.getByText("Тексты панелей")).toBeInTheDocument(); expect(screen.getByText("Тексты панелей")).toBeInTheDocument();
}); });
}); });
+131 -125
View File
@@ -1,125 +1,131 @@
import { DownloadService, type DownloadItem } from "$services/downloadService"; import { DownloadService, type DownloadItem } from "$services/downloadService";
import { saveAs } from "file-saver"; import { saveAs } from "file-saver";
import JSZip from "jszip"; import JSZip from "jszip";
import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Stage } from "konva/lib/Stage";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("file-saver", () => {
return { vi.mock("file-saver", () => {
saveAs: vi.fn(), return {
}; saveAs: vi.fn(),
}); };
});
vi.mock("jszip", () => {
const mockZipInstance = { vi.mock("jszip", () => {
file: vi.fn().mockReturnThis(), const mockZipInstance = {
generateAsync: vi.fn().mockResolvedValue(new Blob([])), file: vi.fn().mockReturnThis(),
}; generateAsync: vi.fn().mockResolvedValue(new Blob([])),
};
return {
default: vi.fn(function () { return {
return mockZipInstance; default: vi.fn(function () {
}), return mockZipInstance;
}; }),
}); };
});
describe("DownloadService", () => {
let service: DownloadService; describe("DownloadService", () => {
let mockKonvaStage: any; let service: DownloadService;
let mockKonvaStage: Stage;
beforeEach(() => {
service = new DownloadService(); beforeEach(() => {
vi.clearAllMocks(); service = new DownloadService();
vi.clearAllMocks();
mockKonvaStage = {
toBlob: vi.fn(({ callback }) => { mockKonvaStage = {
callback(new Blob(["test image data"], { type: "image/png" })); toBlob: vi.fn(async ({ callback }) => {
}), callback(new Blob(["test image data"], { type: "image/png" }));
}; }),
}); } as unknown as Stage;
});
describe("downloadPanel", () => {
it("should export panel successfully", async () => { describe("downloadPanel", () => {
const result = await service.downloadPanel(mockKonvaStage, "test-panel-1"); it("should export panel successfully", async () => {
const result = await service.downloadPanel(mockKonvaStage, "test-panel-1");
const [blobArg, fileNameArg] = vi.mocked(saveAs).mock.calls[0];
const [blobArg, fileNameArg] = vi.mocked(saveAs).mock.calls[0];
expect(result.success).toBe(true);
expect(mockKonvaStage.toBlob).toHaveBeenCalled(); expect(result.success).toBe(true);
expect(blobArg instanceof Blob).toBe(true); expect(mockKonvaStage.toBlob).toHaveBeenCalled();
expect(fileNameArg).toBe("test-panel-1.png"); expect(blobArg instanceof Blob).toBe(true);
}); expect(fileNameArg).toBe("test-panel-1.png");
});
it("should handle Konva stage without toBlob method", async () => {
const invalidStage = { toBlob: undefined }; it("should handle Konva stage without toBlob method", async () => {
const invalidStage = { toBlob: undefined };
const result = await service.downloadPanel(invalidStage as any, "test-panel-1.png");
const result = await service.downloadPanel(
expect(result.success).toBe(false); invalidStage as unknown as Stage,
if (!result.success) { "test-panel-1.png",
expect(result.error).toContain("Konva Stage не найден или не поддерживает toBlob"); );
}
}); expect(result.success).toBe(false);
if (!result.success) {
it("should handle blob creation failure", async () => { expect(result.error).toContain("Konva Stage не найден или не поддерживает toBlob");
mockKonvaStage.toBlob = vi.fn(({ callback }) => { }
callback(null); });
});
it("should handle blob creation failure", async () => {
const result = await service.downloadPanel(mockKonvaStage, "test-panel-1.png"); mockKonvaStage.toBlob = vi.fn(async ({ callback }) => {
callback(null);
expect(result.success).toBe(false); });
if (!result.success) {
expect(result.error).toContain("Не удалось создать изображение"); const result = await service.downloadPanel(mockKonvaStage, "test-panel-1.png");
}
}); expect(result.success).toBe(false);
}); if (!result.success) {
expect(result.error).toContain("Не удалось создать изображение");
describe("downloadAllPanels", () => { }
it("should handle successful download of multiple panels", async () => { });
const panels: Array<DownloadItem> = [{ filename: "test-panel-1", stage: mockKonvaStage }]; });
const result = await service.downloadAll(panels);
const [blobArg, fileNameArg] = vi.mocked(saveAs).mock.calls[0]; describe("downloadAllPanels", () => {
const zipInstance = vi.mocked(JSZip).mock.instances[0]; it("should handle successful download of multiple panels", async () => {
const panels: Array<DownloadItem> = [{ filename: "test-panel-1", stage: mockKonvaStage }];
expect(result.success).toBe(true); const result = await service.downloadAll(panels);
expect(mockKonvaStage.toBlob).toHaveBeenCalled(); const [blobArg, fileNameArg] = vi.mocked(saveAs).mock.calls[0];
expect(blobArg instanceof Blob).toBe(true); const zipInstance = vi.mocked(JSZip).mock.instances[0];
expect(fileNameArg).toBe("panels.zip");
expect(zipInstance.file).toHaveBeenCalledTimes(1); expect(result.success).toBe(true);
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-1.png", expect.anything()); expect(mockKonvaStage.toBlob).toHaveBeenCalled();
}); expect(blobArg instanceof Blob).toBe(true);
expect(fileNameArg).toBe("panels.zip");
it("should add to zip all files", async () => { expect(zipInstance.file).toHaveBeenCalledTimes(1);
const panels: Array<DownloadItem> = [ expect(zipInstance.file).toHaveBeenCalledWith("test-panel-1.png", expect.anything());
{ filename: "test-panel-1", stage: mockKonvaStage }, });
{ filename: "test-panel-2", stage: mockKonvaStage },
{ filename: "test-panel-3", stage: mockKonvaStage }, it("should add to zip all files", async () => {
{ filename: "test-panel-4", stage: mockKonvaStage }, const panels: Array<DownloadItem> = [
{ filename: "test-panel-5", stage: mockKonvaStage }, { filename: "test-panel-1", stage: mockKonvaStage },
]; { filename: "test-panel-2", stage: mockKonvaStage },
const result = await service.downloadAll(panels); { filename: "test-panel-3", stage: mockKonvaStage },
const [blobArg, fileNameArg] = vi.mocked(saveAs).mock.calls[0]; { filename: "test-panel-4", stage: mockKonvaStage },
const zipInstance = vi.mocked(JSZip).mock.instances[0]; { filename: "test-panel-5", stage: mockKonvaStage },
];
expect(result.success).toBe(true); const result = await service.downloadAll(panels);
expect(mockKonvaStage.toBlob).toHaveBeenCalledTimes(5); // const [blobArg, fileNameArg] = vi.mocked(saveAs).mock.calls[0];
expect(zipInstance.file).toHaveBeenCalledTimes(5); const zipInstance = vi.mocked(JSZip).mock.instances[0];
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-1.png", expect.anything());
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-2.png", expect.anything()); expect(result.success).toBe(true);
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-3.png", expect.anything()); expect(mockKonvaStage.toBlob).toHaveBeenCalledTimes(5);
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-4.png", expect.anything()); expect(zipInstance.file).toHaveBeenCalledTimes(5);
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-5.png", expect.anything()); expect(zipInstance.file).toHaveBeenCalledWith("test-panel-1.png", expect.anything());
}); expect(zipInstance.file).toHaveBeenCalledWith("test-panel-2.png", expect.anything());
expect(zipInstance.file).toHaveBeenCalledWith("test-panel-3.png", expect.anything());
it("should handle failure", async () => { expect(zipInstance.file).toHaveBeenCalledWith("test-panel-4.png", expect.anything());
const invalidStage = { toBlob: undefined }; expect(zipInstance.file).toHaveBeenCalledWith("test-panel-5.png", expect.anything());
const panels: Array<DownloadItem> = [{ filename: "test-panel-1", stage: invalidStage as any }]; });
const result = await service.downloadAll(panels);
it("should handle failure", async () => {
expect(result.success).toBe(false); const invalidStage = { toBlob: undefined };
if (!result.success) { const panels: Array<DownloadItem> = [
expect(result.error).toContain("Ошибка сохранения архива"); { filename: "test-panel-1", stage: invalidStage as unknown as Stage },
} ];
}); const result = await service.downloadAll(panels);
});
}); expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain("Ошибка сохранения архива");
}
});
});
});
+216 -192
View File
@@ -1,192 +1,216 @@
import { PANEL_SETTINGS } from "$lib/constants"; import { PANEL_SETTINGS } from "$lib/constants";
import { imageConfigState, ImageConfigState } from "$states/imageConfig.svelte"; import { imageConfigState, ImageConfigState } from "$states/imageConfig.svelte";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
let lastOnload: (() => void) | null = null; type ImageEventHandler = ((this: HTMLImageElement, ev?: Event) => void) | null;
let lastOnerror: (() => void) | null = null; type ImageErrorEventHandler =
| ((
vi.stubGlobal( this: HTMLImageElement,
"Image", ev?: string | Event,
class { source?: string,
_onload: (() => void) | null = null; lineno?: number,
_onerror: (() => void) | null = null; colno?: number,
_src: string = ""; error?: Error,
crossOrigin: string = ""; ) => void)
| null;
set src(val: string) {
this._src = val; let lastOnload: ImageEventHandler = null;
if (val.includes("error")) { let lastOnerror: ImageErrorEventHandler = null;
setTimeout(() => this._onerror?.(), 1);
} else { vi.stubGlobal(
setTimeout(() => this._onload?.(), 1); "Image",
} class {
} _onload: ImageEventHandler = null;
get src() { _onerror: ImageErrorEventHandler = null;
return this._src; _src: string = "";
} crossOrigin: string = "";
set onload(val: any) { set src(val: string) {
this._onload = val; this._src = val;
if (val) lastOnload = val; if (val.includes("error")) {
} setTimeout(
get onload() { () => this._onerror?.call(this as unknown as HTMLImageElement, new Event("error")),
return this._onload; 1,
} );
} else {
set onerror(val: any) { setTimeout(
this._onerror = val; () => this._onload?.call(this as unknown as HTMLImageElement, new Event("load")),
if (val) lastOnerror = val; 1,
} );
get onerror() { }
return this._onerror; }
} get src() {
}, return this._src;
); }
describe("ImageConfigState", () => { set onload(val: ImageEventHandler) {
beforeEach(() => { this._onload = val;
lastOnload = null; if (val) lastOnload = val;
lastOnerror = null; }
imageConfigState.reset(); get onload() {
}); return this._onload;
}
it("should create new instance with default values", () => {
const newState = new ImageConfigState(); set onerror(val: ImageErrorEventHandler) {
expect(newState.image).toBeUndefined(); this._onerror = val;
expect(newState.imageLink).toBe(""); if (val) lastOnerror = val;
expect(newState.imageReady).toBe(false); }
expect(newState.cropLeft).toBe(0); get onerror() {
expect(newState.cropTop).toBe(0); return this._onerror;
expect(newState.cropRight).toBe(0); }
expect(newState.cropBottom).toBe(0); },
newState.destroy(); );
});
describe("ImageConfigState", () => {
it("should initialize with default background image", async () => { beforeEach(() => {
await imageConfigState.uploadImageByLink(PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE); lastOnload = null;
lastOnerror = null;
expect(imageConfigState.imageReady).toBe(true); imageConfigState.reset();
expect(imageConfigState.image).toBeDefined(); });
expect(imageConfigState.imageLink).toBe(PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
}); it("should create new instance with default values", () => {
const newState = new ImageConfigState();
it("should handle manual image upload correctly", async () => { expect(newState.image).toBeUndefined();
const testLink = "https://example.com/test.png"; expect(newState.imageLink).toBe("");
const uploadPromise = imageConfigState.uploadImageByLink(testLink); expect(newState.imageReady).toBe(false);
expect(newState.cropLeft).toBe(0);
expect(imageConfigState.imageReady).toBe(false); expect(newState.cropTop).toBe(0);
expect(newState.cropRight).toBe(0);
await uploadPromise; expect(newState.cropBottom).toBe(0);
expect(imageConfigState.imageReady).toBe(true); newState.destroy();
expect(imageConfigState.imageLink).toBe(testLink); });
});
it("should initialize with default background image", async () => {
it("should reset state to defaults", async () => { await imageConfigState.uploadImageByLink(PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
await imageConfigState.uploadImageByLink("some-image.png");
imageConfigState.cropLeft = 100; expect(imageConfigState.imageReady).toBe(true);
expect(imageConfigState.image).toBeDefined();
imageConfigState.reset(); expect(imageConfigState.imageLink).toBe(PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
});
expect(imageConfigState.imageReady).toBe(false);
expect(imageConfigState.imageLink).toBe(""); it("should handle manual image upload correctly", async () => {
expect(imageConfigState.cropLeft).toBe(0); const testLink = "https://example.com/test.png";
expect(imageConfigState.image).toBeUndefined(); const uploadPromise = imageConfigState.uploadImageByLink(testLink);
});
expect(imageConfigState.imageReady).toBe(false);
it("should handle image loading error", async () => {
await expect(imageConfigState.uploadImageByLink("error-link")).rejects.toThrow("Failed to load image"); await uploadPromise;
expect(imageConfigState.imageReady).toBe(true);
expect(imageConfigState.imageReady).toBe(false); expect(imageConfigState.imageLink).toBe(testLink);
}); });
it("should abort previous upload when new upload starts", async () => { it("should reset state to defaults", async () => {
const upload1 = imageConfigState.uploadImageByLink("test1.jpg"); await imageConfigState.uploadImageByLink("some-image.png");
const upload2 = imageConfigState.uploadImageByLink("test2.jpg"); imageConfigState.cropLeft = 100;
await expect(upload1).rejects.toThrow("Aborted"); imageConfigState.reset();
await expect(upload2).resolves.toBeUndefined();
expect(imageConfigState.imageReady).toBe(false);
expect(imageConfigState.imageLink).toBe("test2.jpg"); expect(imageConfigState.imageLink).toBe("");
}); expect(imageConfigState.cropLeft).toBe(0);
expect(imageConfigState.image).toBeUndefined();
it("should cleanup previous image before loading new one", async () => { });
await imageConfigState.uploadImageByLink("test1.jpg");
const firstImage = imageConfigState.image; it("should handle image loading error", async () => {
await expect(imageConfigState.uploadImageByLink("error-link")).rejects.toThrow(
await imageConfigState.uploadImageByLink("test2.jpg"); "Failed to load image",
);
expect(firstImage?.onload).toBeNull();
expect(firstImage?.onerror).toBeNull(); expect(imageConfigState.imageReady).toBe(false);
}); });
it("should set crop values", () => { it("should abort previous upload when new upload starts", async () => {
imageConfigState.cropLeft = 10; const upload1 = imageConfigState.uploadImageByLink("test1.jpg");
imageConfigState.cropTop = 20; const upload2 = imageConfigState.uploadImageByLink("test2.jpg");
imageConfigState.cropRight = 30;
imageConfigState.cropBottom = 40; await expect(upload1).rejects.toThrow("Aborted");
await expect(upload2).resolves.toBeUndefined();
expect(imageConfigState.cropLeft).toBe(10);
expect(imageConfigState.cropTop).toBe(20); expect(imageConfigState.imageLink).toBe("test2.jpg");
expect(imageConfigState.cropRight).toBe(30); });
expect(imageConfigState.cropBottom).toBe(40);
}); it("should cleanup previous image before loading new one", async () => {
await imageConfigState.uploadImageByLink("test1.jpg");
it("should cleanup image event handlers on reset", async () => { const firstImage = imageConfigState.image;
await imageConfigState.uploadImageByLink("test.jpg");
const img = imageConfigState.image; await imageConfigState.uploadImageByLink("test2.jpg");
imageConfigState.reset(); expect(firstImage?.onload).toBeNull();
expect(firstImage?.onerror).toBeNull();
expect(img?.onload).toBeNull(); });
expect(img?.onerror).toBeNull();
}); it("should set crop values", () => {
imageConfigState.cropLeft = 10;
it("should abort ongoing upload on reset", async () => { imageConfigState.cropTop = 20;
const upload = imageConfigState.uploadImageByLink("test.jpg"); imageConfigState.cropRight = 30;
imageConfigState.cropBottom = 40;
imageConfigState.reset();
expect(imageConfigState.cropLeft).toBe(10);
await expect(upload).rejects.toThrow("Aborted"); expect(imageConfigState.cropTop).toBe(20);
}); expect(imageConfigState.cropRight).toBe(30);
expect(imageConfigState.cropBottom).toBe(40);
it("should cleanup resources on destroy", async () => { });
await imageConfigState.uploadImageByLink("test.jpg");
const img = imageConfigState.image; it("should cleanup image event handlers on reset", async () => {
await imageConfigState.uploadImageByLink("test.jpg");
imageConfigState.destroy(); const img = imageConfigState.image;
expect(imageConfigState.image).toBeUndefined(); imageConfigState.reset();
expect(img?.onload).toBeNull();
expect(img?.onerror).toBeNull(); expect(img?.onload).toBeNull();
}); expect(img?.onerror).toBeNull();
});
it("should abort ongoing upload on destroy", async () => {
const upload = imageConfigState.uploadImageByLink("test.jpg"); it("should abort ongoing upload on reset", async () => {
const upload = imageConfigState.uploadImageByLink("test.jpg");
imageConfigState.destroy();
imageConfigState.reset();
await expect(upload).rejects.toThrow("Aborted");
}); await expect(upload).rejects.toThrow("Aborted");
});
it("should cover aborted onload branch", async () => {
const promise = imageConfigState.uploadImageByLink("test.png"); it("should cleanup resources on destroy", async () => {
await imageConfigState.uploadImageByLink("test.jpg");
imageConfigState.destroy(); const img = imageConfigState.image;
if (lastOnload) lastOnload(); imageConfigState.destroy();
await expect(promise).rejects.toThrow(); expect(imageConfigState.image).toBeUndefined();
expect(imageConfigState.imageReady).toBe(false); expect(img?.onload).toBeNull();
}); expect(img?.onerror).toBeNull();
});
it("should cover aborted onerror branch", async () => {
const promise = imageConfigState.uploadImageByLink("test.png"); it("should abort ongoing upload on destroy", async () => {
const upload = imageConfigState.uploadImageByLink("test.jpg");
imageConfigState.destroy();
imageConfigState.destroy();
if (lastOnerror) lastOnerror();
await expect(upload).rejects.toThrow("Aborted");
await expect(promise).rejects.toThrow(); });
expect(imageConfigState.imageReady).toBe(false);
}); it("should cover aborted onload branch", async () => {
}); const promise = imageConfigState.uploadImageByLink("test.png");
imageConfigState.destroy();
if (lastOnload) {
lastOnload.call(new Image() as HTMLImageElement, new Event("load"));
}
await expect(promise).rejects.toThrow();
expect(imageConfigState.imageReady).toBe(false);
});
it("should cover aborted onerror branch", async () => {
const promise = imageConfigState.uploadImageByLink("test.png");
imageConfigState.destroy();
if (lastOnerror) {
lastOnerror.call(new Image() as HTMLImageElement, new Event("load"));
}
await expect(promise).rejects.toThrow();
expect(imageConfigState.imageReady).toBe(false);
});
});
+8 -8
View File
@@ -1,8 +1,8 @@
import { konvaAllStagesState } from "$states/konvaAllStages.svelte"; import { konvaAllStagesState } from "$states/konvaAllStages.svelte";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
describe("konvaAllStages.svelte", () => { describe("konvaAllStages.svelte", () => {
it("should import successfully", () => { it("should import successfully", () => {
expect(konvaAllStagesState).toBeDefined(); expect(konvaAllStagesState).toBeDefined();
}); });
}); });
+14 -54
View File
@@ -1,54 +1,14 @@
import { konvaStageState } from "$states/konvaStage.svelte"; import { konvaStageState } from "$states/konvaStage.svelte";
import { describe, expect, it } from "vitest"; import type { Stage } from "svelte-konva";
import { describe, expect, it } from "vitest";
describe("konvaStage.svelte", () => {
describe("initial state", () => { describe("konvaStageState integration", () => {
it("should have undefined stage initially", () => { it("should work", () => {
expect(konvaStageState.stage).toBeUndefined(); expect(konvaStageState.stage).toBeUndefined();
});
const mock = { name: "stage" } as unknown as Stage;
it("should have stage getter", () => { konvaStageState.stage = mock;
expect(konvaStageState).toHaveProperty("stage");
}); expect(konvaStageState.stage).toStrictEqual(mock);
});
it("should have stage setter", () => { });
expect(() => {
konvaStageState.stage = {} as any;
}).not.toThrow();
});
});
describe("stage getter", () => {
it("should return current stage", () => {
const mockStage = { id: "test" } as any;
konvaStageState.stage = mockStage;
expect(konvaStageState.stage).toBeDefined();
expect(konvaStageState.stage).toStrictEqual(mockStage);
});
it("should be undefined only at initialization", () => {
expect(konvaStageState.stage).toBeDefined();
});
});
describe("stage setter", () => {
it("should set stage", () => {
const mockStage = { id: "test" } as any;
konvaStageState.stage = mockStage;
expect(konvaStageState.stage).toStrictEqual(mockStage);
});
it("should allow updating stage", () => {
const firstStage = { id: "first" } as any;
const secondStage = { id: "second" } as any;
konvaStageState.stage = firstStage;
expect(konvaStageState.stage).toStrictEqual(firstStage);
konvaStageState.stage = secondStage;
expect(konvaStageState.stage).toStrictEqual(secondStage);
expect(konvaStageState.stage).not.toStrictEqual(firstStage);
});
});
});
+167 -158
View File
@@ -1,158 +1,167 @@
import { STATE_DATA, withPersistence } from "$states/persisted.svelte"; import { STATE_DATA, withPersistence } from "$states/persisted.svelte";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const localStorageMock = (() => { const localStorageMock = (() => {
let store: Record<string, string> = {}; let store: Record<string, string> = {};
return { return {
getItem: vi.fn((key: string) => store[key] || null), getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key: string, value: string) => { setItem: vi.fn((key: string, value: string) => {
store[key] = value; store[key] = value;
}), }),
removeItem: vi.fn((key: string) => { removeItem: vi.fn((key: string) => {
delete store[key]; delete store[key];
}), }),
clear: vi.fn(() => { clear: vi.fn(() => {
store = {}; store = {};
}), }),
}; };
})(); })();
Object.defineProperty(globalThis, "localStorage", { Object.defineProperty(globalThis, "localStorage", {
value: localStorageMock, value: localStorageMock,
}); });
describe("persisted.svelte", () => { describe("persisted.svelte", () => {
beforeEach(() => { beforeEach(() => {
localStorageMock.clear(); localStorageMock.clear();
vi.clearAllMocks(); vi.clearAllMocks();
}); });
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
describe("withPersistence", () => { describe("withPersistence", () => {
it("should return state unchanged when not in browser", () => { it("should return state unchanged when not in browser", () => {
const mockState = { const mockState = {
[STATE_DATA]: { test: "value" }, [STATE_DATA]: { test: "value" },
}; };
const result = withPersistence("test-key", mockState); const result = withPersistence("test-key", mockState);
expect(result).toBe(mockState); expect(result).toBe(mockState);
}); });
it("should restore state from localStorage when valid JSON exists", () => { it("should restore state from localStorage when valid JSON exists", () => {
const savedData = { value: 42, name: "test" }; const savedData = { value: 42, name: "test" };
localStorageMock.getItem.mockReturnValue(JSON.stringify(savedData)); localStorageMock.getItem.mockReturnValue(JSON.stringify(savedData));
const state = { const state = {
[STATE_DATA]: { value: 0, name: "" }, [STATE_DATA]: { value: 0, name: "" },
}; };
withPersistence("test-key", state); withPersistence("test-key", state);
expect(state[STATE_DATA]).toEqual(savedData); expect(state[STATE_DATA]).toEqual(savedData);
expect(localStorageMock.getItem).toHaveBeenCalledWith("test-key"); expect(localStorageMock.getItem).toHaveBeenCalledWith("test-key");
}); });
it("should handle corrupted JSON in localStorage gracefully", () => { it("should handle corrupted JSON in localStorage gracefully", () => {
localStorageMock.getItem.mockReturnValue("{ invalid json"); localStorageMock.getItem.mockReturnValue("{ invalid json");
const state = { const state = {
[STATE_DATA]: { value: 0 }, [STATE_DATA]: { value: 0 },
}; };
expect(() => withPersistence("test-key", state)).not.toThrow(); expect(() => withPersistence("test-key", state)).not.toThrow();
expect(state[STATE_DATA]).toEqual({ value: 0 }); expect(state[STATE_DATA]).toEqual({ value: 0 });
}); });
it("should handle empty localStorage (no saved data)", () => { it("should handle empty localStorage (no saved data)", () => {
localStorageMock.getItem.mockReturnValue(null); localStorageMock.getItem.mockReturnValue(null);
const state = { const state = {
[STATE_DATA]: { value: 0 }, [STATE_DATA]: { value: 0 },
}; };
withPersistence("test-key", state); withPersistence("test-key", state);
expect(state[STATE_DATA]).toEqual({ value: 0 }); expect(state[STATE_DATA]).toEqual({ value: 0 });
}); });
it("should save state to localStorage with debounce", async () => { it("should save state to localStorage with debounce", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const state = { const state = {
[STATE_DATA]: { count: 1 }, [STATE_DATA]: { count: 1 },
}; };
withPersistence("test-key", state, 500); withPersistence("test-key", state, 500);
state[STATE_DATA] = { count: 2 }; state[STATE_DATA] = { count: 2 };
await Promise.resolve(); await Promise.resolve();
vi.advanceTimersByTime(500); vi.advanceTimersByTime(500);
await Promise.resolve(); await Promise.resolve();
expect(localStorageMock.setItem).toHaveBeenCalledWith("test-key", JSON.stringify({ count: 2 })); expect(localStorageMock.setItem).toHaveBeenCalledWith(
"test-key",
vi.useRealTimers(); JSON.stringify({ count: 2 }),
}); );
it("should save state immediately when debounce is 0", async () => { vi.useRealTimers();
const state = { });
[STATE_DATA]: { count: 1 },
}; it("should save state immediately when debounce is 0", async () => {
const state = {
withPersistence("test-key", state, 0); [STATE_DATA]: { count: 1 },
};
state[STATE_DATA] = { count: 2 };
withPersistence("test-key", state, 0);
await Promise.resolve();
state[STATE_DATA] = { count: 2 };
expect(localStorageMock.setItem).toHaveBeenCalledWith("test-key", JSON.stringify({ count: 2 }));
}); await Promise.resolve();
it("should cleanup timeout on state change during debounce", async () => { expect(localStorageMock.setItem).toHaveBeenCalledWith(
vi.useFakeTimers(); "test-key",
const data = $state({ count: 1 }); JSON.stringify({ count: 2 }),
);
const state = { });
get [STATE_DATA]() {
return data; it("should cleanup timeout on state change during debounce", async () => {
}, vi.useFakeTimers();
set [STATE_DATA](v) { const data = $state({ count: 1 });
data.count = v.count;
}, const state = {
}; get [STATE_DATA]() {
return data;
withPersistence("test-key", state, 500); },
set [STATE_DATA](v) {
state[STATE_DATA] = { count: 2 }; data.count = v.count;
await Promise.resolve(); },
};
state[STATE_DATA] = { count: 3 };
await Promise.resolve(); withPersistence("test-key", state, 500);
vi.runOnlyPendingTimers(); state[STATE_DATA] = { count: 2 };
await Promise.resolve(); await Promise.resolve();
expect(localStorageMock.setItem).toHaveBeenCalledTimes(1); state[STATE_DATA] = { count: 3 };
expect(localStorageMock.setItem).toHaveBeenCalledWith("test-key", JSON.stringify({ count: 3 })); await Promise.resolve();
vi.useRealTimers(); vi.runOnlyPendingTimers();
}); await Promise.resolve();
it("should use DEBOUNCE_DURATION constant as default", async () => { expect(localStorageMock.setItem).toHaveBeenCalledTimes(1);
const state = { expect(localStorageMock.setItem).toHaveBeenCalledWith(
[STATE_DATA]: { test: true }, "test-key",
}; JSON.stringify({ count: 3 }),
);
withPersistence("test-key", state);
vi.useRealTimers();
state[STATE_DATA] = { test: false }; });
await Promise.resolve(); it("should use DEBOUNCE_DURATION constant as default", async () => {
const state = {
expect(localStorageMock.setItem).toHaveBeenCalled(); [STATE_DATA]: { test: true },
}); };
});
}); withPersistence("test-key", state);
state[STATE_DATA] = { test: false };
await Promise.resolve();
expect(localStorageMock.setItem).toHaveBeenCalled();
});
});
});
+79 -79
View File
@@ -1,79 +1,79 @@
import { TextAlign } from "$lib/constants"; import { TextAlign } from "$lib/constants";
import type { HexColor } from "$lib/types"; import type { HexColor } from "$lib/types";
import { STATE_DATA } from "$states/persisted.svelte"; import { STATE_DATA } from "$states/persisted.svelte";
import { textConfigState } from "$states/textConfig.svelte"; import { textConfigState } from "$states/textConfig.svelte";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
const TEXT_ALIGN_VALUES = Object.values(TextAlign); const TEXT_ALIGN_VALUES = Object.values(TextAlign);
describe("textConfig.svelte", () => { describe("textConfig.svelte", () => {
describe("initial state", () => { describe("initial state", () => {
it("should have valid initial state structure", () => { it("should have valid initial state structure", () => {
expect(typeof textConfigState.fontSize).toBe("number"); expect(typeof textConfigState.fontSize).toBe("number");
expect(typeof textConfigState.fontFamily).toBe("string"); expect(typeof textConfigState.fontFamily).toBe("string");
expect(typeof textConfigState.color).toBe("string"); expect(typeof textConfigState.color).toBe("string");
expect(textConfigState.color).toMatch(/^#[0-9a-fA-F]{6}$/); expect(textConfigState.color).toMatch(/^#[0-9a-fA-F]{6}$/);
expect(TEXT_ALIGN_VALUES).toContain(textConfigState.align); expect(TEXT_ALIGN_VALUES).toContain(textConfigState.align);
expect(typeof textConfigState.paddingX).toBe("number"); expect(typeof textConfigState.paddingX).toBe("number");
expect(typeof textConfigState.offsetY).toBe("number"); expect(typeof textConfigState.offsetY).toBe("number");
}); });
}); });
describe("property setters", () => { describe("property setters", () => {
it("should update all properties correctly", () => { it("should update all properties correctly", () => {
textConfigState.fontSize = 32; textConfigState.fontSize = 32;
textConfigState.fontFamily = "Roboto"; textConfigState.fontFamily = "Roboto";
textConfigState.color = "#ff0000" as HexColor; textConfigState.color = "#ff0000" as HexColor;
textConfigState.align = TextAlign.LEFT; textConfigState.align = TextAlign.LEFT;
textConfigState.paddingX = 20; textConfigState.paddingX = 20;
textConfigState.offsetY = -10; textConfigState.offsetY = -10;
expect(textConfigState.fontSize).toBe(32); expect(textConfigState.fontSize).toBe(32);
expect(textConfigState.fontFamily).toBe("Roboto"); expect(textConfigState.fontFamily).toBe("Roboto");
expect(textConfigState.color).toBe("#ff0000"); expect(textConfigState.color).toBe("#ff0000");
expect(textConfigState.align).toBe(TextAlign.LEFT); expect(textConfigState.align).toBe(TextAlign.LEFT);
expect(textConfigState.paddingX).toBe(20); expect(textConfigState.paddingX).toBe(20);
expect(textConfigState.offsetY).toBe(-10); expect(textConfigState.offsetY).toBe(-10);
}); });
}); });
describe("STATE_DATA", () => { describe("STATE_DATA", () => {
it("should serialize and deserialize state", () => { it("should serialize and deserialize state", () => {
// Set custom values // Set custom values
textConfigState.fontSize = 18; textConfigState.fontSize = 18;
textConfigState.fontFamily = "Georgia"; textConfigState.fontFamily = "Georgia";
textConfigState.color = "#00ff00" as HexColor; textConfigState.color = "#00ff00" as HexColor;
textConfigState.align = TextAlign.RIGHT; textConfigState.align = TextAlign.RIGHT;
textConfigState.paddingX = 5; textConfigState.paddingX = 5;
textConfigState.offsetY = 15; textConfigState.offsetY = 15;
// Get serialized data // Get serialized data
const data = textConfigState[STATE_DATA]; const data = textConfigState[STATE_DATA];
expect(data).toEqual({ expect(data).toEqual({
fontSize: 18, fontSize: 18,
fontFamily: "Georgia", fontFamily: "Georgia",
color: "#00ff00", color: "#00ff00",
align: TextAlign.RIGHT, align: TextAlign.RIGHT,
paddingX: 5, paddingX: 5,
offsetY: 15, offsetY: 15,
}); });
// Restore from serialized data // Restore from serialized data
textConfigState[STATE_DATA] = { textConfigState[STATE_DATA] = {
fontSize: 24, fontSize: 24,
fontFamily: "Arial", fontFamily: "Arial",
color: "#ffffff" as HexColor, color: "#ffffff" as HexColor,
align: TextAlign.CENTER, align: TextAlign.CENTER,
paddingX: 10, paddingX: 10,
offsetY: 0, offsetY: 0,
}; };
expect(textConfigState.fontSize).toBe(24); expect(textConfigState.fontSize).toBe(24);
expect(textConfigState.fontFamily).toBe("Arial"); expect(textConfigState.fontFamily).toBe("Arial");
expect(textConfigState.color).toBe("#ffffff"); expect(textConfigState.color).toBe("#ffffff");
expect(textConfigState.align).toBe(TextAlign.CENTER); expect(textConfigState.align).toBe(TextAlign.CENTER);
expect(textConfigState.paddingX).toBe(10); expect(textConfigState.paddingX).toBe(10);
expect(textConfigState.offsetY).toBe(0); expect(textConfigState.offsetY).toBe(0);
}); });
}); });
}); });
+95 -95
View File
@@ -1,95 +1,95 @@
import { STATE_DATA } from "$states/persisted.svelte"; import { STATE_DATA } from "$states/persisted.svelte";
import { createState } from "$states/texts.svelte"; import { createState } from "$states/texts.svelte";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
describe("texts.svelte", () => { describe("texts.svelte", () => {
describe("addText", () => { describe("addText", () => {
it("should add new text with unique ID", () => { it("should add new text with unique ID", () => {
const state = createState(); const state = createState();
const initialLength = state.texts.length; const initialLength = state.texts.length;
state.addText("Test text"); state.addText("Test text");
expect(state.texts).toHaveLength(initialLength + 1); expect(state.texts).toHaveLength(initialLength + 1);
expect(state.texts[initialLength].text).toBe("Test text"); expect(state.texts[initialLength].text).toBe("Test text");
expect(state.texts[initialLength].id).toBeDefined(); expect(state.texts[initialLength].id).toBeDefined();
}); });
it("should not add empty text", () => { it("should not add empty text", () => {
const state = createState(); const state = createState();
const initialLength = state.texts.length; const initialLength = state.texts.length;
state.addText(""); state.addText("");
state.addText(" "); state.addText(" ");
expect(state.texts).toHaveLength(initialLength); expect(state.texts).toHaveLength(initialLength);
}); });
}); });
describe("removeText", () => { describe("removeText", () => {
it("should remove text by ID", () => { it("should remove text by ID", () => {
const state = createState(); const state = createState();
const idToRemove = state.texts[0].id; const idToRemove = state.texts[0].id;
const initialLength = state.texts.length; const initialLength = state.texts.length;
state.removeText(idToRemove); state.removeText(idToRemove);
expect(state.texts).toHaveLength(initialLength - 1); expect(state.texts).toHaveLength(initialLength - 1);
expect(state.texts.find((item) => item.id === idToRemove)).toBeUndefined(); expect(state.texts.find((item) => item.id === idToRemove)).toBeUndefined();
}); });
it("should not affect other texts when removing", () => { it("should not affect other texts when removing", () => {
const state = createState(); const state = createState();
const remainingTexts = state.texts.filter((item) => item.id !== state.texts[0].id); const remainingTexts = state.texts.filter((item) => item.id !== state.texts[0].id);
const idToRemove = state.texts[0].id; const idToRemove = state.texts[0].id;
state.removeText(idToRemove); state.removeText(idToRemove);
expect(state.texts).toStrictEqual(remainingTexts); expect(state.texts).toStrictEqual(remainingTexts);
}); });
}); });
describe("clear", () => { describe("clear", () => {
it("should remove all texts", () => { it("should remove all texts", () => {
const state = createState(); const state = createState();
state.addText("Test 1"); state.addText("Test 1");
state.addText("Test 2"); state.addText("Test 2");
state.addText("Test 3"); state.addText("Test 3");
state.clear(); state.clear();
expect(state.texts).toHaveLength(0); expect(state.texts).toHaveLength(0);
}); });
}); });
describe("STATE_DATA", () => { describe("STATE_DATA", () => {
it("should serialize and deserialize texts", () => { it("should serialize and deserialize texts", () => {
const state = createState(); const state = createState();
state.clear(); state.clear();
state.addText("First"); state.addText("First");
state.addText("Second"); state.addText("Second");
state.addText("Third"); state.addText("Third");
const data = state[STATE_DATA]; const data = state[STATE_DATA];
expect(data).toEqual(["First", "Second", "Third"]); expect(data).toEqual(["First", "Second", "Third"]);
// Restore from serialized data // Restore from serialized data
state[STATE_DATA] = ["New 1", "New 2"]; state[STATE_DATA] = ["New 1", "New 2"];
expect(state.texts).toHaveLength(2); expect(state.texts).toHaveLength(2);
expect(state.texts[0].text).toBe("New 1"); expect(state.texts[0].text).toBe("New 1");
expect(state.texts[1].text).toBe("New 2"); expect(state.texts[1].text).toBe("New 2");
expect(state.texts[0].id).toBe(0); expect(state.texts[0].id).toBe(0);
expect(state.texts[1].id).toBe(1); expect(state.texts[1].id).toBe(1);
}); });
it("should handle empty array", () => { it("should handle empty array", () => {
const state = createState(); const state = createState();
state.addText("Test"); state.addText("Test");
state[STATE_DATA] = []; state[STATE_DATA] = [];
expect(state.texts).toHaveLength(0); expect(state.texts).toHaveLength(0);
}); });
}); });
}); });
+45 -45
View File
@@ -1,45 +1,45 @@
import { Theme } from "$lib/constants"; import { Theme } from "$lib/constants";
import { STATE_DATA } from "$states/persisted.svelte"; import { STATE_DATA } from "$states/persisted.svelte";
import { themeState } from "$states/theme.svelte"; import { themeState } from "$states/theme.svelte";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
const THEME_VALUES = Object.values(Theme); const THEME_VALUES = Object.values(Theme);
describe("theme.svelte", () => { describe("theme.svelte", () => {
describe("initial state", () => { describe("initial state", () => {
it("should have valid initial theme value", () => { it("should have valid initial theme value", () => {
expect(THEME_VALUES).toContain(themeState.current); expect(THEME_VALUES).toContain(themeState.current);
}); });
}); });
describe("toggle", () => { describe("toggle", () => {
it("should toggle between themes", () => { it("should toggle between themes", () => {
themeState.current = Theme.LIGHT; themeState.current = Theme.LIGHT;
themeState.toggle(); themeState.toggle();
expect(themeState.current).toBe(Theme.DARK); expect(themeState.current).toBe(Theme.DARK);
themeState.toggle(); themeState.toggle();
expect(themeState.current).toBe(Theme.LIGHT); expect(themeState.current).toBe(Theme.LIGHT);
}); });
}); });
describe("current setter", () => { describe("current setter", () => {
it("should set theme correctly", () => { it("should set theme correctly", () => {
themeState.current = Theme.DARK; themeState.current = Theme.DARK;
expect(themeState.current).toBe(Theme.DARK); expect(themeState.current).toBe(Theme.DARK);
themeState.current = Theme.LIGHT; themeState.current = Theme.LIGHT;
expect(themeState.current).toBe(Theme.LIGHT); expect(themeState.current).toBe(Theme.LIGHT);
}); });
}); });
describe("STATE_DATA", () => { describe("STATE_DATA", () => {
it("should serialize and deserialize theme", () => { it("should serialize and deserialize theme", () => {
themeState.current = Theme.DARK; themeState.current = Theme.DARK;
expect(themeState[STATE_DATA]).toEqual({ current: Theme.DARK }); expect(themeState[STATE_DATA]).toEqual({ current: Theme.DARK });
themeState[STATE_DATA] = { current: Theme.LIGHT }; themeState[STATE_DATA] = { current: Theme.LIGHT };
expect(themeState.current).toBe(Theme.LIGHT); expect(themeState.current).toBe(Theme.LIGHT);
}); });
}); });
}); });

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