feat: continue impemelent mvp components and stores

This commit is contained in:
2026-02-03 21:54:10 +05:00
parent ac864e3791
commit f317ac1030
7 changed files with 1307 additions and 19 deletions
+19 -19
View File
@@ -59,35 +59,35 @@ src/
### **Image Cropping Interface**
- [ ] Integrate cropperjs library
- [ ] Create responsive crop interface
- [ ] Add crop ratio constraints (320px width)
- [ ] Implement crop confirmation and cancellation
- [ ] Handle crop errors and edge cases
- [x] Integrate cropperjs library
- [x] Create responsive crop interface
- [x] Add crop ratio constraints (320px width)
- [x] Implement crop confirmation and cancellation
- [x] Handle crop errors and edge cases
### **Text Management System**
- [ ] Create dynamic text list component
- [ ] Implement add/edit/delete text functionality
- [ ] Add text positioning controls
- [ ] Implement text styling options (font, size, color)
- [ ] Add text validation and error handling
- [x] Create dynamic text list component
- [x] Implement add/edit/delete text functionality
- [x] Add text positioning controls
- [x] Implement text styling options (font, size, color)
- [x] Add text validation and error handling
### **Canvas Rendering Engine**
- [ ] Upgrade SvelteKonva implementation
- [ ] Create dynamic height support
- [ ] Implement real-time preview updates
- [ ] Add layer management system
- [x] Upgrade SvelteKonva implementation
- [x] Create dynamic height support
- [x] Implement real-time preview updates
- [x] Add layer management system
- [ ] Optimize rendering performance
### **Panel Management**
- [ ] Create panel storage system
- [ ] Implement panel navigation (previous/next)
- [ ] Add panel deletion functionality
- [ ] Create panel export queue
- [ ] Implement panel validation
- [x] Create panel storage system
- [x] Implement panel navigation (previous/next)
- [x] Add panel deletion functionality
- [x] Create panel export queue
- [x] Implement panel validation
### **Batch Download System**
+227
View File
@@ -0,0 +1,227 @@
<script lang="ts">
import { onMount } from "svelte";
import Cropper from "cropperjs";
import type { ImageCropResult } from "../lib/types/panel";
import { ImageService } from "../lib/services/imageService";
import { uiStore } from "../stores/uiStore";
import "cropperjs/dist/cropper.css";
export let imageSrc: string;
export let onCropComplete: (croppedImage: string) => void;
export let onCancel: () => void;
let imageElement: HTMLImageElement;
let cropper: Cropper | null = null;
let isProcessing = $state(false);
let errorMessage = $state<string | null>(null);
let imageService = new ImageService();
onMount(() => {
initializeCropper();
return () => {
if (cropper) {
cropper.destroy();
}
};
});
function initializeCropper() {
if (!imageElement) return;
cropper = new Cropper(imageElement, {
aspectRatio: NaN, // Allow any aspect ratio
viewMode: 1,
dragMode: "move",
autoCropArea: 1,
restore: false,
guides: true,
center: true,
highlight: false,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: false,
minCropBoxWidth: 320,
minCropBoxHeight: 50,
responsive: true,
});
}
async function handleCrop() {
if (!cropper) return;
try {
isProcessing = true;
errorMessage = null;
uiStore.setLoading(true);
const canvas = cropper.getCroppedCanvas({
width: 320,
imageSmoothingEnabled: true,
imageSmoothingQuality: "high",
});
if (!canvas) {
throw new Error("Не удалось получить обрезанное изображение");
}
const croppedImage = canvas.toDataURL("image/png");
// Validate cropped image
const cropResult: ImageCropResult = {
success: true,
croppedImage,
};
onCropComplete(croppedImage);
} catch (error) {
errorMessage = error instanceof Error ? error.message : "Ошибка обрезки изображения";
console.error("Crop error:", error);
} finally {
isProcessing = false;
uiStore.setLoading(false);
}
}
function handleCancel() {
if (cropper) {
cropper.destroy();
}
onCancel();
}
</script>
<div class="cropper-container">
<div class="cropper-wrapper">
<img
bind:this={imageElement}
src={imageSrc}
alt="Image to crop"
class="cropper-image"
/>
</div>
{#if errorMessage}
<div class="error-message">
<strong>Ошибка:</strong> {errorMessage}
</div>
{/if}
<div class="cropper-actions">
<button
class="btn btn-secondary"
on:click={handleCancel}
disabled={isProcessing}
>
Отмена
</button>
<button
class="btn btn-primary"
on:click={handleCrop}
disabled={isProcessing}
>
{isProcessing ? "Обработка..." : "Применить"}
</button>
</div>
<div class="cropper-info">
<p>💡 Изображение будет обрезано до ширины 320px</p>
</div>
</div>
<style>
.cropper-container {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
max-width: 800px;
margin: 0 auto;
}
.cropper-wrapper {
position: relative;
width: 100%;
max-height: 600px;
overflow: hidden;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.cropper-image {
display: block;
max-width: 100%;
height: auto;
}
.cropper-actions {
display: flex;
gap: 1rem;
justify-content: center;
padding: 1rem;
background: #f9f9f9;
border-radius: 8px;
}
.cropper-info {
text-align: center;
color: #666;
font-size: 0.9rem;
padding: 0.5rem;
}
.error-message {
background: #ffebee;
color: #c62828;
padding: 0.75rem;
border-radius: 4px;
border: 1px solid #ffcdd2;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 500;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #0056b3;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover:not(:disabled) {
background: #545b62;
}
:global(.cropper-view-box),
:global(.cropper-face) {
border-radius: 0;
}
:global(.cropper-line) {
background-color: rgba(0, 123, 255, 0.5);
}
:global(.cropper-point) {
background-color: #007bff;
}
</style>
+285
View File
@@ -0,0 +1,285 @@
<script lang="ts">
import { onMount } from "svelte";
import { panelStore } from "../stores/panelStore";
import { panelStorage } from "../lib/utils/panelStorage";
import type { Panel } from "../lib/types/panel";
export let onPanelSelect: (panel: Panel) => void;
export let onPanelDelete: (panelId: string) => void;
let panels = $state<Panel[]>([]);
let selectedPanelId = $state<string | null>(null);
let errorMessage = $state<string | null>(null);
onMount(() => {
loadPanels();
});
function loadPanels() {
try {
errorMessage = null;
panels = panelStorage.getAllPanels();
// Если есть текущая панель в store, отмечаем её как выбранную
const currentPanel = $panelStore;
if (currentPanel) {
selectedPanelId = currentPanel.id;
}
} catch (error) {
errorMessage = error instanceof Error ? error.message : "Ошибка загрузки панелей";
}
}
function handlePanelSelect(panel: Panel) {
selectedPanelId = panel.id;
onPanelSelect(panel);
}
function handlePanelDelete(panelId: string) {
try {
errorMessage = null;
const result = panelStorage.deletePanel(panelId);
if (result.success) {
panels = panels.filter((p) => p.id !== panelId);
// Если удалили выбранную панель, сбрасываем выбор
if (selectedPanelId === panelId) {
selectedPanelId = null;
}
// Уведомляем родительский компонент
onPanelDelete(panelId);
} else {
errorMessage = result.error || "Ошибка удаления панели";
}
} catch (error) {
errorMessage = error instanceof Error ? error.message : "Ошибка удаления панели";
}
}
function formatDate(date: Date): string {
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
</script>
<div class="panel-list">
<div class="panel-list-header">
<h2>Сохраненные панели</h2>
<button class="btn btn-refresh" on:click={loadPanels} aria-label="Обновить список">
</button>
</div>
{#if errorMessage}
<div class="error-message">
{errorMessage}
</div>
{/if}
{#if panels.length === 0}
<div class="empty-state">
<p>Нет сохраненных панелей</p>
</div>
{:else}
<div class="panels-grid">
{#each panels as panel (panel.id)}
<div
class="panel-card {selectedPanelId === panel.id ? 'selected' : ''}"
on:click={() => handlePanelSelect(panel)}
role="button"
tabindex="0"
>
<div class="panel-preview">
{#if panel.backgroundImage}
<img
src={panel.backgroundImage}
alt="Panel preview"
class="panel-image"
/>
{:else}
<div class="no-image">Нет изображения</div>
{/if}
</div>
<div class="panel-info">
<div class="panel-meta">
<span class="panel-id">ID: {panel.id.slice(0, 8)}...</span>
<span class="panel-date">{formatDate(panel.updatedAt)}</span>
</div>
<div class="panel-actions">
<button
class="btn-icon btn-delete"
on:click|stopPropagation={() => handlePanelDelete(panel.id)}
aria-label="Удалить панель"
>
🗑️
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.panel-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
width: 100%;
max-width: 1200px;
margin: 0 auto;
}
.panel-list-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: #f9f9f9;
border-radius: 8px;
}
.panel-list-header h2 {
margin: 0;
color: #333;
font-size: 1.5rem;
}
.btn-refresh {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 0.5rem;
color: #666;
transition: all 0.2s ease;
}
.btn-refresh:hover {
color: #007bff;
transform: rotate(180deg);
}
.error-message {
background: #ffebee;
color: #c62828;
padding: 0.75rem;
border-radius: 4px;
border: 1px solid #ffcdd2;
}
.empty-state {
text-align: center;
padding: 2rem;
color: #666;
background: #f9f9f9;
border-radius: 8px;
}
.empty-state p {
margin: 0;
}
.panels-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.panel-card {
border: 2px solid #ddd;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s ease;
background: white;
}
.panel-card:hover {
border-color: #007bff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.panel-card.selected {
border-color: #007bff;
background: #f0f8ff;
}
.panel-preview {
position: relative;
width: 100%;
padding-top: 31.25%; /* 320:100 aspect ratio */
background: #f5f5f5;
}
.panel-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.no-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 0.9rem;
}
.panel-info {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.panel-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
color: #666;
}
.panel-id {
font-family: monospace;
}
.panel-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.btn-icon {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem 0.5rem;
font-size: 1.2rem;
transition: all 0.2s ease;
}
.btn-delete:hover {
transform: scale(1.1);
}
</style>
+155
View File
@@ -0,0 +1,155 @@
<script lang="ts">
import { onMount } from "svelte";
import { panelStore } from "../stores/panelStore";
import type { Panel } from "../lib/types/panel";
import type { PageProps } from "./$types";
export let onDownload?: () => void;
let Stage: any = $state(undefined);
let Layer: any = $state(undefined);
let Image: any = $state(undefined);
let Text: any = $state(undefined);
let currentPanel = $state<Panel | null>(null);
let backgroundImage: HTMLImageElement | undefined = $state(undefined);
$effect(() => {
currentPanel = $panelStore;
if (currentPanel?.backgroundImage) {
loadImage(currentPanel.backgroundImage);
}
});
onMount(async () => {
const svelteKonva = await import("svelte-konva");
Stage = svelteKonva.Stage;
Layer = svelteKonva.Layer;
Image = svelteKonva.Image;
Text = svelteKonva.Text;
});
function loadImage(src: string) {
const img = new Image();
img.onload = () => {
backgroundImage = img;
};
img.src = src;
}
function handleDownload() {
if (onDownload) {
onDownload();
}
}
</script>
<div class="panel-preview">
<div class="preview-header">
<h2>Предпросмотр панели</h2>
{#if currentPanel}
<button class="btn btn-primary" on:click={handleDownload}>
Скачать
</button>
{/if}
</div>
{#if currentPanel && Stage}
<div class="canvas-container">
<Stage width={320} height={currentPanel.height}>
<Layer>
{#if backgroundImage}
<Image
image={backgroundImage}
width={320}
height={currentPanel.height}
/>
{/if}
{#each currentPanel.texts as textItem (textItem.id)}
<Text
text={textItem.text}
fontSize={textItem.fontSize}
fill={textItem.color}
fontFamily={textItem.fontFamily}
x={textItem.x}
y={textItem.y}
align="center"
width={320}
/>
{/each}
</Layer>
</Stage>
</div>
{:else}
<div class="empty-state">
<p>Нет данных для предпросмотра</p>
</div>
{/if}
</div>
<style>
.panel-preview {
display: flex;
flex-direction: column;
gap: 1.5rem;
width: 100%;
max-width: 800px;
margin: 0 auto;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: #f9f9f9;
border-radius: 8px;
}
.preview-header h2 {
margin: 0;
color: #333;
font-size: 1.5rem;
}
.canvas-container {
display: flex;
justify-content: center;
padding: 2rem;
background: #f5f5f5;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.empty-state {
text-align: center;
padding: 2rem;
color: #666;
background: #f9f9f9;
border-radius: 8px;
}
.empty-state p {
margin: 0;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 500;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-primary:hover {
background: #0056b3;
}
</style>
+367
View File
@@ -0,0 +1,367 @@
<script lang="ts">
import { panelStore, addTextToPanel, updateTextInPanel, removeTextFromPanel } from "../stores/panelStore";
import type { TextItem } from "../lib/types/panel";
import { v4 as uuidv4 } from "uuid";
export let onTextUpdate: (texts: TextItem[]) => void;
let currentPanel = $state($panelStore);
let selectedTextId = $state<string | null>(null);
let newText = $state("");
let errorMessage = $state<string | null>(null);
// Subscribe to panel store
$effect(() => {
currentPanel = $panelStore;
});
function handleAddText() {
if (!currentPanel) return;
if (!newText.trim()) {
errorMessage = "Введите текст";
return;
}
if (newText.length > 100) {
errorMessage = "Текст не должен превышать 100 символов";
return;
}
try {
errorMessage = null;
const updatedPanel = addTextToPanel(currentPanel, newText.trim());
panelStore.set(updatedPanel);
onTextUpdate(updatedPanel.texts);
newText = "";
selectedTextId = updatedPanel.texts[updatedPanel.texts.length - 1].id;
} catch (error) {
errorMessage = error instanceof Error ? error.message : "Ошибка добавления текста";
}
}
function handleUpdateText(textId: string, updates: Partial<TextItem>) {
if (!currentPanel) return;
try {
errorMessage = null;
const updatedPanel = updateTextInPanel(currentPanel, textId, updates);
panelStore.set(updatedPanel);
onTextUpdate(updatedPanel.texts);
} catch (error) {
errorMessage = error instanceof Error ? error.message : "Ошибка обновления текста";
}
}
function handleRemoveText(textId: string) {
if (!currentPanel) return;
try {
errorMessage = null;
const updatedPanel = removeTextFromPanel(currentPanel, textId);
panelStore.set(updatedPanel);
onTextUpdate(updatedPanel.texts);
if (selectedTextId === textId) {
selectedTextId = null;
}
} catch (error) {
errorMessage = error instanceof Error ? error.message : "Ошибка удаления текста";
}
}
function handleSelectText(textId: string) {
selectedTextId = textId;
}
</script>
<div class="text-manager">
<div class="text-manager-header">
<h2>Управление текстом</h2>
</div>
<div class="add-text-section">
<div class="input-group">
<input
type="text"
bind:value={newText}
placeholder="Введите текст..."
class="text-input"
maxlength="100"
/>
<button class="btn btn-primary" on:click={handleAddText}>
Добавить
</button>
</div>
{#if errorMessage}
<div class="error-message">
{errorMessage}
</div>
{/if}
</div>
{#if currentPanel && currentPanel.texts.length > 0}
<div class="text-list">
{#each currentPanel.texts as textItem (textItem.id)}
<div
class="text-item {selectedTextId === textItem.id ? 'selected' : ''}"
on:click={() => handleSelectText(textItem.id)}
>
<div class="text-preview">
<span class="text-content">{textItem.text}</span>
<button
class="btn-icon btn-delete"
on:click|stopPropagation={() => handleRemoveText(textItem.id)}
aria-label="Удалить текст"
>
×
</button>
</div>
{#if selectedTextId === textItem.id}
<div class="text-controls">
<div class="control-group">
<label>
Размер шрифта:
<input
type="range"
min="10"
max="72"
step="1"
value={textItem.fontSize}
on:input={(e) => handleUpdateText(textItem.id, {
fontSize: parseInt(e.target.value)
})}
/>
<span class="value-display">{textItem.fontSize}px</span>
</label>
</div>
<div class="control-group">
<label>
Цвет:
<input
type="color"
value={textItem.color}
on:input={(e) => handleUpdateText(textItem.id, {
color: e.target.value
})}
/>
</label>
</div>
<div class="control-group">
<label>
Позиция X:
<input
type="range"
min="0"
max="320"
step="1"
value={textItem.x}
on:input={(e) => handleUpdateText(textItem.id, {
x: parseInt(e.target.value)
})}
/>
<span class="value-display">{textItem.x}px</span>
</label>
</div>
<div class="control-group">
<label>
Позиция Y:
<input
type="range"
min="0"
max={currentPanel.height}
step="1"
value={textItem.y}
on:input={(e) => handleUpdateText(textItem.id, {
y: parseInt(e.target.value)
})}
/>
<span class="value-display">{textItem.y}px</span>
</label>
</div>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="empty-state">
<p>Нет добавленного текста</p>
</div>
{/if}
</div>
<style>
.text-manager {
display: flex;
flex-direction: column;
gap: 1.5rem;
width: 100%;
max-width: 600px;
margin: 0 auto;
}
.text-manager-header h2 {
margin: 0;
color: #333;
font-size: 1.5rem;
}
.add-text-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.input-group {
display: flex;
gap: 0.5rem;
}
.text-input {
flex: 1;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.text-input:focus {
outline: none;
border-color: #007bff;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 500;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-primary:hover {
background: #0056b3;
}
.error-message {
background: #ffebee;
color: #c62828;
padding: 0.75rem;
border-radius: 4px;
border: 1px solid #ffcdd2;
font-size: 0.9rem;
}
.text-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.text-item {
border: 2px solid #ddd;
border-radius: 8px;
overflow: hidden;
transition: all 0.3s ease;
}
.text-item.selected {
border-color: #007bff;
background: #f0f8ff;
}
.text-preview {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: #f9f9f9;
cursor: pointer;
}
.text-content {
font-size: 1rem;
color: #333;
flex: 1;
}
.btn-icon {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem 0.5rem;
font-size: 1.5rem;
color: #dc3545;
transition: all 0.2s ease;
}
.btn-icon:hover {
color: #a71d2a;
}
.text-controls {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.control-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.control-group label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: #666;
}
.control-group input[type="range"] {
flex: 1;
cursor: pointer;
}
.control-group input[type="color"] {
width: 40px;
height: 40px;
border: none;
border-radius: 4px;
cursor: pointer;
padding: 0;
}
.value-display {
min-width: 40px;
text-align: right;
font-weight: 500;
color: #333;
}
.empty-state {
text-align: center;
padding: 2rem;
color: #666;
background: #f9f9f9;
border-radius: 8px;
}
.empty-state p {
margin: 0;
}
</style>
+96
View File
@@ -0,0 +1,96 @@
import { saveAs } from "file-saver";
import type { Panel } from "../types/panel";
import { ImageError } from "../types/errors";
import { handleError, logError } from "../utils/errorHandler";
export interface ExportResult {
success: boolean;
error?: string;
}
export class ExportService {
/**
* Экспортирует панель в изображение и сохраняет её
*/
async exportPanel(panel: Panel, filename?: string): Promise<ExportResult> {
try {
if (!panel.backgroundImage) {
throw new ImageError("Нет фонового изображения для экспорта");
}
// Создаем canvas для экспорта
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new ImageError("Не удалось создать контекст canvas");
}
// Устанавливаем размеры canvas
canvas.width = 320;
canvas.height = panel.height;
// Загружаем фоновое изображение
const bgImage = await this.loadImage(panel.backgroundImage);
// Рисуем фон
ctx.drawImage(bgImage, 0, 0, 320, panel.height);
// Рисуем текст
panel.texts.forEach((textItem) => {
ctx.font = `${textItem.fontSize}px ${textItem.fontFamily}`;
ctx.fillStyle = textItem.color;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(textItem.text, textItem.x, textItem.y);
});
// Конвертируем в blob и сохраняем
canvas.toBlob((blob) => {
if (!blob) {
throw new ImageError("Не удалось создать изображение");
}
const defaultFilename = `twitch-panel-${panel.id}.png`;
saveAs(blob, filename || defaultFilename);
}, "image/png");
return {
success: true,
};
} catch (error) {
logError(error, "Panel export failed");
return {
success: false,
error: handleError(error, "Ошибка экспорта панели"),
};
}
}
/**
* Загружает изображение из base64 строки
*/
private loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new ImageError("Не удалось загрузить изображение"));
img.src = src;
});
}
/**
* Экспортирует несколько панелей в ZIP архив
* (будет реализовано позже для batch download)
*/
async exportPanels(panels: Panel[]): Promise<ExportResult> {
// TODO: Реализовать пакетный экспорт с использованием JSZip
return {
success: false,
error: "Пакетный экспорт еще не реализован",
};
}
}
export const exportService = new ExportService();
+158
View File
@@ -0,0 +1,158 @@
import type { Panel } from "../types/panel";
import { ImageError } from "../types/errors";
import { handleError, logError } from "./errorHandler";
const STORAGE_KEY = "twitch-panels";
const MAX_PANELS = 50; // Максимальное количество сохраненных панелей
export interface StorageResult {
success: boolean;
error?: string;
}
export class PanelStorage {
/**
* Сохраняет панель в localStorage
*/
savePanel(panel: Panel): StorageResult {
try {
const panels = this.getAllPanels();
// Проверяем, существует ли панель с таким ID
const existingIndex = panels.findIndex((p) => p.id === panel.id);
if (existingIndex >= 0) {
// Обновляем существующую панель
panels[existingIndex] = panel;
} else {
// Добавляем новую панель в начало списка
panels.unshift(panel);
// Проверяем лимит количества панелей
if (panels.length > MAX_PANELS) {
panels.length = MAX_PANELS;
}
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(panels));
return {
success: true,
};
} catch (error) {
logError(error, "Failed to save panel");
return {
success: false,
error: handleError(error, "Ошибка сохранения панели"),
};
}
}
/**
* Загружает все панели из localStorage
*/
getAllPanels(): Panel[] {
try {
const data = localStorage.getItem(STORAGE_KEY);
if (!data) {
return [];
}
const panels: Panel[] = JSON.parse(data);
// Валидация загруженных панелей
return panels.filter((panel) => this.validatePanel(panel));
} catch (error) {
logError(error, "Failed to load panels");
return [];
}
}
/**
* Загружает панель по ID
*/
getPanelById(id: string): Panel | null {
try {
const panels = this.getAllPanels();
return panels.find((p) => p.id === id) || null;
} catch (error) {
logError(error, "Failed to get panel by id");
return null;
}
}
/**
* Удаляет панель по ID
*/
deletePanel(id: string): StorageResult {
try {
const panels = this.getAllPanels();
const filteredPanels = panels.filter((p) => p.id !== id);
localStorage.setItem(STORAGE_KEY, JSON.stringify(filteredPanels));
return {
success: true,
};
} catch (error) {
logError(error, "Failed to delete panel");
return {
success: false,
error: handleError(error, "Ошибка удаления панели"),
};
}
}
/**
* Очищает все сохраненные панели
*/
clearAll(): StorageResult {
try {
localStorage.removeItem(STORAGE_KEY);
return {
success: true,
};
} catch (error) {
logError(error, "Failed to clear panels");
return {
success: false,
error: handleError(error, "Ошибка очистки хранилища"),
};
}
}
/**
* Валидирует панель
*/
private validatePanel(panel: any): panel is Panel {
return (
typeof panel === "object" &&
typeof panel.id === "string" &&
typeof panel.backgroundImage === "string" &&
Array.isArray(panel.texts) &&
typeof panel.height === "number" &&
panel.height > 0 &&
panel.height <= 1000 &&
panel.createdAt instanceof Date &&
panel.updatedAt instanceof Date
);
}
/**
* Получает количество сохраненных панелей
*/
getPanelCount(): number {
return this.getAllPanels().length;
}
/**
* Проверяет, есть ли место для новой панели
*/
hasSpaceForNewPanel(): boolean {
return this.getPanelCount() < MAX_PANELS;
}
}
export const panelStorage = new PanelStorage();