refactor: separate components into folders
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
<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";
|
||||
import { IconButton } from "$lib/components/ui";
|
||||
|
||||
interface Props {
|
||||
onPanelSelect: (panel: Panel) => void;
|
||||
onPanelDelete: (panelId: string) => void;
|
||||
}
|
||||
let { onPanelSelect, onPanelDelete }: Props = $props();
|
||||
|
||||
let panels = $state<Panel[]>([]);
|
||||
let selectedPanelId = $state<string | undefined>(undefined);
|
||||
let errorMessage = $state<string | undefined>(undefined);
|
||||
|
||||
onMount(() => {
|
||||
loadPanels();
|
||||
});
|
||||
|
||||
function loadPanels() {
|
||||
try {
|
||||
errorMessage = undefined;
|
||||
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 = undefined;
|
||||
const result = panelStorage.deletePanel(panelId);
|
||||
|
||||
if (result.success) {
|
||||
panels = panels.filter((p) => p.id !== panelId);
|
||||
|
||||
// Если удалили выбранную панель, сбрасываем выбор
|
||||
if (selectedPanelId === panelId) {
|
||||
selectedPanelId = undefined;
|
||||
}
|
||||
|
||||
// Уведомляем родительский компонент
|
||||
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>
|
||||
<IconButton variant="secondary" onclick={loadPanels} ariaLabel="Обновить список">↻</IconButton>
|
||||
</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)}
|
||||
<button
|
||||
class="panel-card {selectedPanelId === panel.id ? 'selected' : ''}"
|
||||
onclick={() => handlePanelSelect(panel)}
|
||||
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">
|
||||
<IconButton
|
||||
variant="danger"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePanelDelete(panel.id);
|
||||
}}
|
||||
ariaLabel="Удалить панель"
|
||||
>
|
||||
🗑️
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,184 @@
|
||||
<script lang="ts">
|
||||
import { uiStore, setCurrentStep } from "../../stores/uiStore";
|
||||
import type { Panel } from "$lib/types/panel";
|
||||
|
||||
type TextItem = Panel["texts"][0];
|
||||
import { Stage, Layer, Image, Text } from "svelte-konva";
|
||||
import { Button } from "$lib/components/ui";
|
||||
|
||||
interface Props {
|
||||
panel: Panel;
|
||||
onDownload?: () => void;
|
||||
}
|
||||
|
||||
let { panel, onDownload }: Props = $props();
|
||||
|
||||
let backgroundImage: HTMLImageElement | undefined = $state(undefined);
|
||||
|
||||
function handleUploadNewImage() {
|
||||
setCurrentStep("upload");
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (panel?.backgroundImage && panel.backgroundImage !== backgroundImage?.src) {
|
||||
loadImage(panel.backgroundImage);
|
||||
}
|
||||
});
|
||||
|
||||
function loadImage(src: string) {
|
||||
const img = document.createElement("img");
|
||||
img.onload = () => {
|
||||
backgroundImage = img;
|
||||
};
|
||||
img.src = src;
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
if (onDownload) {
|
||||
onDownload();
|
||||
}
|
||||
}
|
||||
|
||||
function getTextPosition(textItem: TextItem) {
|
||||
const panelWidth = 320;
|
||||
const paddingX = textItem.paddingX || 0;
|
||||
const verticalOffset = textItem.verticalOffset || 0;
|
||||
const centerY = panel.height / 2 + verticalOffset;
|
||||
|
||||
switch (textItem.textAlign) {
|
||||
case "left":
|
||||
return { x: paddingX, y: centerY };
|
||||
case "right":
|
||||
return { x: panelWidth - paddingX, y: centerY };
|
||||
case "center":
|
||||
default:
|
||||
return { x: panelWidth / 2, y: centerY };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="panel-preview">
|
||||
<div class="preview-header">
|
||||
<div class="panel-title">{panel.texts[0]?.text || "Без названия"}</div>
|
||||
<Button variant="primary" size="sm" onclick={handleDownload}>Скачать</Button>
|
||||
</div>
|
||||
<div class="preview-sections">
|
||||
<!-- Превью с текстом -->
|
||||
<div class="preview-section">
|
||||
<div class="canvas-container">
|
||||
<Stage width={320} height={panel.height}>
|
||||
<Layer>
|
||||
{#if backgroundImage}
|
||||
<Image image={backgroundImage} width={320} height={panel.height} />
|
||||
{/if}
|
||||
{#each panel.texts || [] as textItem (textItem.id)}
|
||||
{@const textPosition = getTextPosition(textItem)}
|
||||
<Text
|
||||
text={textItem.text}
|
||||
fontSize={textItem.fontSize}
|
||||
fill={textItem.color}
|
||||
fontFamily={textItem.fontFamily}
|
||||
x={textPosition.x}
|
||||
y={textPosition.y}
|
||||
align={textItem.textAlign}
|
||||
width={320 - textItem.paddingX * 2}
|
||||
/>
|
||||
{/each}
|
||||
</Layer>
|
||||
</Stage>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.panel-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.edit-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.edit-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.edit-input:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.preview-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.preview-section h3 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
color: #666;
|
||||
background: #f9f9f9;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import type { Panel } from "$lib/types/panel";
|
||||
import PanelPreview from "./PanelPreview.svelte";
|
||||
import { Button } from "$lib/components/ui";
|
||||
|
||||
interface Props {
|
||||
panels: Panel[];
|
||||
onDownload: (panel: Panel) => void;
|
||||
onDownloadAll: () => void;
|
||||
}
|
||||
let { panels, onDownload, onDownloadAll }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if panels.length > 0}
|
||||
<div class="panels-container">
|
||||
<div class="panels-header">
|
||||
<h2>Созданные панели ({panels.length})</h2>
|
||||
<Button variant="primary" onclick={onDownloadAll}>Скачать все</Button>
|
||||
</div>
|
||||
<div class="panels-list">
|
||||
{#each panels as panel (panel.id)}
|
||||
<div class="panel-item">
|
||||
<PanelPreview {panel} onDownload={() => onDownload(panel)} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.panels-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.panels-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.panels-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.panels-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.panel-item {
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user