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

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