+78
@@ -0,0 +1,78 @@
|
||||
:root {
|
||||
--brand-main: #86efac;
|
||||
--brand-alt: oklch(from var(--brand-main) l c calc(h + 36));
|
||||
|
||||
--action-lightness: 0.6;
|
||||
--action-chroma: 0.18;
|
||||
--hover-step: -0.08;
|
||||
|
||||
--danger-base: #ef4444;
|
||||
|
||||
--surface-main: oklch(from var(--brand-main) 0.99 0.02 h);
|
||||
--surface-subtle: oklch(from var(--brand-main) 0.95 0.1 h);
|
||||
--surface-elevated: oklch(from var(--brand-main) 0.99 0.05 h);
|
||||
--surface-control: oklch(from var(--brand-main) 0.85 0.04 h);
|
||||
--surface-hover: oklch(from var(--brand-main) 0.9 0.1 h);
|
||||
|
||||
--action-primary: oklch(from var(--brand-main) var(--action-lightness) var(--action-chroma) h);
|
||||
--action-primary-hover: oklch(from var(--action-primary) calc(l - var(--hover-step)) c h);
|
||||
--action-secondary: oklch(from var(--brand-alt) var(--action-lightness) var(--action-chroma) h);
|
||||
--action-secondary-hover: oklch(from var(--action-secondary) calc(l - var(--hover-step)) c h);
|
||||
|
||||
--text-main: oklch(from var(--brand-main) 0.25 0.02 h);
|
||||
--text-muted: oklch(from var(--brand-main) 0.45 0.02 h);
|
||||
--text-action: oklch(from var(--brand-main) 0.99 0.02 h);
|
||||
|
||||
--border-main: oklch(from var(--brand-main) 0.9 0.04 h);
|
||||
--border-strong: oklch(from var(--brand-main) 0.8 0.06 h);
|
||||
|
||||
--shadow: 0 1px 3px oklch(from var(--brand-main) 0.2 0.05 h / 0.1);
|
||||
--shadow-md: 0 4px 12px oklch(from var(--brand-main) 0.2 0.05 h / 0.1);
|
||||
|
||||
--radius: 8px;
|
||||
--transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--action-lightness: 0.5;
|
||||
--action-chroma: 0.18;
|
||||
|
||||
--surface-main: oklch(from var(--brand-main) 0.12 0.02 h);
|
||||
--surface-subtle: oklch(from var(--brand-main) 0.16 0.03 h);
|
||||
--surface-elevated: oklch(from var(--brand-main) 0.2 0.03 h);
|
||||
--surface-control: oklch(from var(--brand-main) 0.4 0.06 h);
|
||||
--surface-hover: oklch(from var(--brand-main) 0.24 0.04 h);
|
||||
|
||||
--action-primary-hover: oklch(from var(--action-primary) calc(l + var(--hover-step)) c h);
|
||||
--action-secondary-hover: oklch(from var(--action-secondary) calc(l + var(--hover-step)) c h);
|
||||
|
||||
--text-main: oklch(from var(--brand-main) 0.94 0.01 h);
|
||||
--text-muted: oklch(from var(--brand-main) 0.75 0.02 h);
|
||||
|
||||
--border-main: oklch(from var(--brand-main) 0.26 0.04 h);
|
||||
--border-strong: oklch(from var(--brand-main) 0.4 0.06 h);
|
||||
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
text-indent: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif;
|
||||
background: var(--surface-main);
|
||||
color: var(--text-main);
|
||||
min-height: 100vh;
|
||||
padding: 16px;
|
||||
transition: var(--transition);
|
||||
line-height: 1.5;
|
||||
}
|
||||
+21
-8
@@ -1,11 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
const saved = localStorage.getItem("theme");
|
||||
if (saved) {
|
||||
const theme = JSON.parse(saved).theme;
|
||||
if (theme === "dark") {
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
errorMessage: string | undefined;
|
||||
}
|
||||
|
||||
let { errorMessage }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if errorMessage}
|
||||
<div class="error-message">
|
||||
{errorMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.error-message {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ffcdd2;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -1,53 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
|
||||
interface Props {
|
||||
backgroundImage?: string | undefined;
|
||||
onUploadNewImage: () => void;
|
||||
}
|
||||
|
||||
let { backgroundImage, onUploadNewImage }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if backgroundImage}
|
||||
<div class="background-preview">
|
||||
<div class="background-header">
|
||||
<h3>Фоновое изображение</h3>
|
||||
<Button variant="secondary" size="sm" onclick={onUploadNewImage}>Загрузить</Button>
|
||||
</div>
|
||||
<img src={backgroundImage} alt="Фон" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.background-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.background-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.background-preview h3 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.background-preview img {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import { IMAGE_SETTINGS, PANEL_SETTINGS } from "$lib/constants";
|
||||
import { imageState } from "$states/image.svelte";
|
||||
import Konva from "konva";
|
||||
import { onMount } from "svelte";
|
||||
import { Image, Layer, Stage } from "svelte-konva";
|
||||
|
||||
let active = $state(false);
|
||||
let masterImage: Image | undefined = $state();
|
||||
|
||||
let contrast = $derived(imageState.appliedFilters.contrast);
|
||||
let brightness = $derived(imageState.appliedFilters.brightness);
|
||||
let hue = $derived(imageState.appliedFilters.hue);
|
||||
let saturation = $derived(imageState.appliedFilters.saturation);
|
||||
let luminance = $derived(imageState.appliedFilters.luminance);
|
||||
|
||||
function updateMasterImage() {
|
||||
if (masterImage?.node && masterImage?.node?.width() > 0) {
|
||||
masterImage?.node.cache();
|
||||
imageState.masterImage = masterImage?.node.toCanvas();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void contrast;
|
||||
void brightness;
|
||||
void hue;
|
||||
void saturation;
|
||||
void luminance;
|
||||
updateMasterImage();
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
updateMasterImage();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="crop-canvas-container">
|
||||
<Stage width={PANEL_SETTINGS.PANEL_WIDTH} height={PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT}>
|
||||
<Layer>
|
||||
<Image
|
||||
image={imageState.croppedImage}
|
||||
bind:this={masterImage}
|
||||
filters={[Konva.Filters.Brightness, Konva.Filters.Contrast, Konva.Filters.HSL]}
|
||||
brightness={brightness / IMAGE_SETTINGS.BRIGHTNESS_DIVIDER}
|
||||
{contrast}
|
||||
{hue}
|
||||
saturation={saturation / IMAGE_SETTINGS.SATURATION_DIVIDER}
|
||||
luminance={luminance / IMAGE_SETTINGS.LUMINANCE_DIVIDER}
|
||||
/>
|
||||
</Layer>
|
||||
</Stage>
|
||||
|
||||
<div class="crop-box" class:active>
|
||||
<div class="crop-handle nw"></div>
|
||||
<div class="crop-handle ne"></div>
|
||||
<div class="crop-handle sw"></div>
|
||||
<div class="crop-handle se"></div>
|
||||
<div class="crop-handle n"></div>
|
||||
<div class="crop-handle s"></div>
|
||||
<div class="crop-handle w"></div>
|
||||
<div class="crop-handle e"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.crop-canvas-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 5;
|
||||
background: var(--surface-subtle);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.crop-box {
|
||||
position: absolute;
|
||||
border: 2px solid var(--action-primary);
|
||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
|
||||
cursor: move;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.crop-box.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.crop-handle {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: white;
|
||||
border: 2px solid var(--action-primary);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.crop-handle.nw {
|
||||
top: -5px;
|
||||
left: -5px;
|
||||
cursor: nw-resize;
|
||||
}
|
||||
|
||||
.crop-handle.ne {
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
cursor: ne-resize;
|
||||
}
|
||||
|
||||
.crop-handle.sw {
|
||||
bottom: -5px;
|
||||
left: -5px;
|
||||
cursor: sw-resize;
|
||||
}
|
||||
|
||||
.crop-handle.se {
|
||||
bottom: -5px;
|
||||
right: -5px;
|
||||
cursor: se-resize;
|
||||
}
|
||||
|
||||
.crop-handle.n {
|
||||
top: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
cursor: n-resize;
|
||||
}
|
||||
|
||||
.crop-handle.s {
|
||||
bottom: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
cursor: s-resize;
|
||||
}
|
||||
|
||||
.crop-handle.w {
|
||||
left: -5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
cursor: w-resize;
|
||||
}
|
||||
|
||||
.crop-handle.e {
|
||||
right: -5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
cursor: e-resize;
|
||||
}
|
||||
</style>
|
||||
@@ -1,214 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import type { ImageCropResult } from "$lib/types/panel";
|
||||
import { setLoading } from "$stores/uiStore";
|
||||
import CropperJS from "cropperjs";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
interface Props {
|
||||
imageSrc: string;
|
||||
onCropComplete: (croppedImage: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let { imageSrc, onCropComplete, onCancel }: Props = $props();
|
||||
|
||||
let imageElement: HTMLImageElement;
|
||||
let cropper: CropperJS | undefined = undefined;
|
||||
let isProcessing = $state(false);
|
||||
let errorMessage = $state<string | undefined>(undefined);
|
||||
|
||||
onMount(() => {
|
||||
initializeCropper();
|
||||
return () => {
|
||||
if (cropper) {
|
||||
cropper.destroy();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
function initializeCropper() {
|
||||
if (!imageElement || !CropperJS) {
|
||||
return;
|
||||
}
|
||||
|
||||
cropper = new CropperJS(imageElement);
|
||||
}
|
||||
|
||||
async function handleCrop() {
|
||||
if (!cropper) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isProcessing = true;
|
||||
errorMessage = undefined;
|
||||
setLoading(true);
|
||||
|
||||
const cropperCanvasElement = cropper.getCropperCanvas();
|
||||
|
||||
if (!cropperCanvasElement) {
|
||||
throw new Error("Не удалось получить обрезанное изображение");
|
||||
}
|
||||
|
||||
const canvas = await cropperCanvasElement.$toCanvas({
|
||||
width: 320,
|
||||
});
|
||||
|
||||
if (!canvas) {
|
||||
throw new Error("Не удалось получить canvas");
|
||||
}
|
||||
|
||||
const croppedImage = canvas.toDataURL("image/png");
|
||||
|
||||
const cropResult: ImageCropResult = {
|
||||
success: true,
|
||||
croppedImage,
|
||||
};
|
||||
|
||||
onCropComplete(croppedImage);
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : "Ошибка обрезки изображения";
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (cropper) {
|
||||
cropper.destroy();
|
||||
}
|
||||
onCancel();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="cropper-container">
|
||||
<div class="cropper-wrapper">
|
||||
<img bind:this={imageElement} src={imageSrc} alt="" class="cropper-image" />
|
||||
</div>
|
||||
|
||||
{#if errorMessage}
|
||||
<div class="error-message">
|
||||
<strong>Ошибка:</strong>
|
||||
{errorMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="cropper-actions">
|
||||
<Button variant="secondary" onclick={handleCancel} disabled={isProcessing}>Отмена</Button>
|
||||
<Button variant="primary" onclick={handleCrop} disabled={isProcessing} loading={isProcessing}>Применить</Button>
|
||||
</div>
|
||||
|
||||
<div class="cropper-info">
|
||||
<p>💡 Изображение будет обрезано до ширины 320px</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cropper-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.cropper-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-height: 600px;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.cropper-image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.cropper-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.cropper-info {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ffcdd2;
|
||||
}
|
||||
|
||||
:global(.cropper-view-box),
|
||||
:global(.cropper-face) {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
:global(.cropper-line) {
|
||||
background-color: rgba(0, 123, 255, 0.5);
|
||||
}
|
||||
|
||||
:global(.cropper-point) {
|
||||
background-color: #007bff;
|
||||
}
|
||||
|
||||
:global(.cropper-container) {
|
||||
direction: ltr;
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
:global(.cropper-container img) {
|
||||
display: block;
|
||||
height: 100%;
|
||||
image-orientation: 0deg;
|
||||
max-height: none !important;
|
||||
max-width: none !important;
|
||||
min-height: 0 !important;
|
||||
min-width: 0 !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:global(.cropper-wrap-box, .cropper-canvas, .cropper-drag-box, .cropper-crop-box, .cropper-modal) {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
:global(.cropper-wrap-box, .cropper-canvas) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(.cropper-drag-box) {
|
||||
background-color: #fff;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
:global(.cropper-modal) {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
:global(.cropper-crop-box) {
|
||||
border-color: rgba(0, 123, 255, 0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -1,20 +1,97 @@
|
||||
<script lang="ts">
|
||||
import ImageUpload from "./ImageUpload.svelte";
|
||||
import ImageCropper from "./ImageCropper.svelte";
|
||||
|
||||
interface Props {
|
||||
currentStep: string;
|
||||
uploadedImage: string | undefined;
|
||||
onImageUpload: (image: string) => void;
|
||||
onCropComplete: (croppedImage: string) => void;
|
||||
onCropCancel: () => void;
|
||||
}
|
||||
|
||||
let { currentStep, uploadedImage, onImageUpload, onCropComplete, onCropCancel }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if currentStep === "upload"}
|
||||
<ImageUpload onImageSelect={onImageUpload} />
|
||||
{:else if currentStep === "crop" && uploadedImage}
|
||||
<ImageCropper imageSrc={uploadedImage} {onCropComplete} onCancel={onCropCancel} />
|
||||
{/if}
|
||||
<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 { IMAGE_SETTINGS } from "$lib/constants";
|
||||
import { uploadService } from "$services/uploadService";
|
||||
import { imageState } from "$states/image.svelte";
|
||||
import Pencil from "~icons/lucide/pencil";
|
||||
import Reset from "~icons/lucide/rotate-ccw";
|
||||
import Sliders from "~icons/lucide/sliders-horizontal";
|
||||
import Upload from "~icons/lucide/upload";
|
||||
import CropInline from "./CropInline.svelte";
|
||||
|
||||
async function handlePaste(event: ClipboardEvent) {
|
||||
let image = await uploadService.fromClipboard(event);
|
||||
if (image.ok) {
|
||||
imageState.fullImage = image.data;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card title="Фоновое изображение">
|
||||
<div class="crop-editor">
|
||||
<CropInline />
|
||||
|
||||
<div class="crop-controls">
|
||||
<Button label="Загрузить" ariaLabel="Upload" icon={Upload} type="primary" />
|
||||
<Button label="Редактировать" ariaLabel="Edit" icon={Pencil} type="secondary" />
|
||||
<Button label="Сбросить" ariaLabel="Reset" icon={Reset} type="outline" />
|
||||
</div>
|
||||
|
||||
<SettingsGrid label="Настройки изображения" icon={Sliders}>
|
||||
<SettingsRow label="Сдвиг цвета">
|
||||
<RangeSlider
|
||||
bind:value={imageState.hue}
|
||||
min={IMAGE_SETTINGS.HUE_MIN}
|
||||
max={IMAGE_SETTINGS.HUE_MAX}
|
||||
defaultValue={IMAGE_SETTINGS.HUE_DEFAULT}
|
||||
showReset={true}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Насыщенность">
|
||||
<RangeSlider
|
||||
bind:value={imageState.saturation}
|
||||
min={IMAGE_SETTINGS.SATURATION_MIN}
|
||||
max={IMAGE_SETTINGS.SATURATION_MAX}
|
||||
defaultValue={IMAGE_SETTINGS.SATURATION_DEFAULT}
|
||||
showReset={true}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Яркость">
|
||||
<RangeSlider
|
||||
bind:value={imageState.luminance}
|
||||
min={IMAGE_SETTINGS.LUMINANCE_MIN}
|
||||
max={IMAGE_SETTINGS.LUMINANCE_MAX}
|
||||
defaultValue={IMAGE_SETTINGS.LUMINANCE_DEFAULT}
|
||||
showReset={true}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Светлота">
|
||||
<RangeSlider
|
||||
bind:value={imageState.brightness}
|
||||
min={IMAGE_SETTINGS.BRIGHTNESS_MIN}
|
||||
max={IMAGE_SETTINGS.BRIGHTNESS_MAX}
|
||||
defaultValue={IMAGE_SETTINGS.BRIGHTNESS_DEFAULT}
|
||||
showReset={true}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Контраст">
|
||||
<RangeSlider
|
||||
bind:value={imageState.contrast}
|
||||
min={IMAGE_SETTINGS.CONTRAST_MIN}
|
||||
max={IMAGE_SETTINGS.CONTRAST_MAX}
|
||||
defaultValue={IMAGE_SETTINGS.CONTRAST_DEFAULT}
|
||||
showReset={true}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsGrid>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<svelte:window onpaste={handlePaste} />
|
||||
|
||||
<style>
|
||||
.crop-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.crop-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import type { ImageUploadResult } from "$lib/types/panel";
|
||||
import { handleError } from "$lib/utils/errorHandler";
|
||||
import { clearError, setCurrentStep, setLoading, uiStore } from "$stores/uiStore";
|
||||
import { onMount } from "svelte";
|
||||
import { imageService } from "../../services/imageService";
|
||||
|
||||
interface Props {
|
||||
onImageSelect: (image: string) => void;
|
||||
}
|
||||
|
||||
let { onImageSelect }: Props = $props();
|
||||
|
||||
let dropZone = $state<HTMLElement | undefined>(undefined);
|
||||
let fileInput = $state<HTMLInputElement | undefined>(undefined);
|
||||
let urlInput = $state<HTMLInputElement | undefined>(undefined);
|
||||
let urlForm = $state<HTMLFormElement | undefined>(undefined);
|
||||
|
||||
let isDragOver = $state(false);
|
||||
let showUrlInput = $state(false);
|
||||
let uploadedImage = $state<string | undefined>(undefined);
|
||||
let errorMessage = $state<string | undefined>(undefined);
|
||||
|
||||
const uiError = $derived($uiStore.error);
|
||||
|
||||
onMount(() => {
|
||||
setupPasteHandler();
|
||||
setupDragAndDrop();
|
||||
});
|
||||
|
||||
function setupPasteHandler() {
|
||||
document.addEventListener("paste", handlePaste);
|
||||
return () => document.removeEventListener("paste", handlePaste);
|
||||
}
|
||||
|
||||
function setupDragAndDrop() {
|
||||
if (!dropZone) return;
|
||||
|
||||
dropZone!.addEventListener("dragover", handleDragOver);
|
||||
dropZone!.addEventListener("dragleave", handleDragLeave);
|
||||
dropZone!.addEventListener("drop", handleDrop);
|
||||
|
||||
return () => {
|
||||
dropZone!.removeEventListener("dragover", handleDragOver);
|
||||
dropZone!.removeEventListener("dragleave", handleDragLeave);
|
||||
dropZone!.removeEventListener("drop", handleDrop);
|
||||
};
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
isDragOver = true;
|
||||
}
|
||||
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
isDragOver = false;
|
||||
}
|
||||
|
||||
async function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
isDragOver = false;
|
||||
|
||||
const files = e.dataTransfer?.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
await handleFileUpload(file);
|
||||
}
|
||||
|
||||
async function handleFileUpload(file: File) {
|
||||
try {
|
||||
setLoading(true);
|
||||
clearError();
|
||||
const result: ImageUploadResult = await imageService.handleFileUpload(file);
|
||||
|
||||
if (result.success && result.image) {
|
||||
uploadedImage = result.image;
|
||||
onImageSelect(result.image);
|
||||
setCurrentStep("crop");
|
||||
} else {
|
||||
errorMessage = result.error || "Ошибка загрузки файла";
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage = handleError(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePaste(e: ClipboardEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
clearError();
|
||||
|
||||
const result: ImageUploadResult = await imageService.handlePasteUpload(e);
|
||||
|
||||
if (result.success && result.image) {
|
||||
uploadedImage = result.image;
|
||||
|
||||
onImageSelect(result.image);
|
||||
setCurrentStep("crop");
|
||||
} else {
|
||||
errorMessage = result.error || "Ошибка вставки изображения";
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage = handleError(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUrlSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!urlInput?.value.trim()) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
clearError();
|
||||
|
||||
const result: ImageUploadResult = await imageService.handleUrlUpload(urlInput.value.trim());
|
||||
|
||||
if (result.success && result.image) {
|
||||
uploadedImage = result.image;
|
||||
|
||||
onImageSelect(result.image);
|
||||
setCurrentStep("crop");
|
||||
showUrlInput = false;
|
||||
} else {
|
||||
errorMessage = result.error || "Ошибка загрузки изображения";
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage = handleError(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInput?.click();
|
||||
}
|
||||
|
||||
function resetUpload() {
|
||||
uploadedImage = undefined;
|
||||
errorMessage = undefined;
|
||||
showUrlInput = false;
|
||||
setCurrentStep("upload");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="image-upload-container">
|
||||
{#if uploadedImage}
|
||||
<div class="uploaded-image-preview">
|
||||
<img src={uploadedImage} alt="Uploaded preview" />
|
||||
<div class="preview-actions">
|
||||
<Button variant="secondary" onclick={resetUpload}>Загрузить другое</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
bind:this={dropZone}
|
||||
class="drop-zone {isDragOver ? 'drag-over' : ''} {uiError ? 'error' : ''}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={triggerFileInput}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
triggerFileInput();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="drop-zone-content">
|
||||
<svg class="upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
|
||||
<h3>Загрузите изображение</h3>
|
||||
<p>Перетащите файл сюда, нажмите для выбора, или вставьте через Ctrl+V</p>
|
||||
|
||||
<div class="upload-methods">
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
triggerFileInput();
|
||||
}}>Выбрать файл</Button
|
||||
>
|
||||
|
||||
<Button variant="secondary" onclick={() => (showUrlInput = !showUrlInput)}>
|
||||
{showUrlInput ? "Скрыть" : "По URL"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if showUrlInput}
|
||||
<form bind:this={urlForm} onsubmit={handleUrlSubmit} class="url-input-form">
|
||||
<input
|
||||
bind:this={urlInput}
|
||||
type="url"
|
||||
placeholder="https://example.com/image.jpg"
|
||||
required
|
||||
class="url-input"
|
||||
/>
|
||||
<Button type="submit" variant="primary" disabled={$uiStore.isLoading} loading={$uiStore.isLoading}
|
||||
>Загрузить</Button
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div class="upload-tips">
|
||||
<p>• Поддерживаемые форматы: JPG, PNG, WebP, GIF</p>
|
||||
<p>• Максимальный размер: 10MB</p>
|
||||
<p>• Или вставьте изображение через Ctrl+V</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="image/jpeg,image/jpg,image/png,image/webp,image/gif"
|
||||
style="display: none;"
|
||||
onchange={(e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) handleFileUpload(file);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if errorMessage}
|
||||
<div class="error-message">
|
||||
<strong>Ошибка:</strong>
|
||||
{errorMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $uiStore.isLoading}
|
||||
<div class="loading-overlay">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Загрузка...</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.image-upload-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
background: #f9f9f9;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.drop-zone:hover {
|
||||
border-color: #007bff;
|
||||
background: #f0f8ff;
|
||||
}
|
||||
|
||||
.drop-zone.drag-over {
|
||||
border-color: #007bff;
|
||||
background: #e3f2fd;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.drop-zone.error {
|
||||
border-color: #dc3545;
|
||||
background: #ffebee;
|
||||
}
|
||||
|
||||
.drop-zone-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.drop-zone h3 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.drop-zone p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.upload-methods {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.url-input-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.url-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.upload-tips {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.upload-tips p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.uploaded-image-preview {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.uploaded-image-preview img {
|
||||
max-width: 100%;
|
||||
max-height: 300px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-top: 1rem;
|
||||
border: 1px solid #ffcdd2;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #007bff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,72 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ErrorMessage from "$components/feedback/ErrorMessage.svelte";
|
||||
import AppContent from "$components/layout/AppContent.svelte";
|
||||
import AppHeader from "$components/layout/AppHeader.svelte";
|
||||
|
||||
interface Props {
|
||||
errorMessage: string | undefined;
|
||||
uploadedImage: string | undefined;
|
||||
texts: Array<{ id: string; text: string }>;
|
||||
backgroundImage: string | undefined;
|
||||
panels: any[];
|
||||
onUploadNewImage: () => void;
|
||||
onImageUpload: (image: string) => void;
|
||||
onCropComplete: (croppedImage: string) => void;
|
||||
onCropCancel: () => void;
|
||||
onTextAdd: (text: string) => void;
|
||||
onTextUpdate: (id: string, newText: string) => void;
|
||||
onTextDelete: (id: string) => void;
|
||||
onDownload: (panel: any, konvaStage: any) => Promise<void>;
|
||||
onDownloadAll: () => Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
errorMessage,
|
||||
uploadedImage,
|
||||
texts,
|
||||
backgroundImage,
|
||||
panels,
|
||||
onUploadNewImage,
|
||||
onImageUpload,
|
||||
onCropComplete,
|
||||
onCropCancel,
|
||||
onTextAdd,
|
||||
onTextUpdate,
|
||||
onTextDelete,
|
||||
onDownload,
|
||||
onDownloadAll,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="app-container">
|
||||
<AppHeader />
|
||||
|
||||
<ErrorMessage {errorMessage} />
|
||||
|
||||
<AppContent
|
||||
{uploadedImage}
|
||||
{texts}
|
||||
{backgroundImage}
|
||||
{panels}
|
||||
{onImageUpload}
|
||||
{onCropComplete}
|
||||
{onCropCancel}
|
||||
{onTextAdd}
|
||||
{onTextUpdate}
|
||||
{onTextDelete}
|
||||
{onUploadNewImage}
|
||||
{onDownload}
|
||||
{onDownloadAll}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,64 +0,0 @@
|
||||
<script lang="ts">
|
||||
import MainSection from "$components/layout/MainSection.svelte";
|
||||
import Sidebar from "$components/layout/Sidebar.svelte";
|
||||
|
||||
interface Props {
|
||||
uploadedImage: string | undefined;
|
||||
texts: Array<{ id: string; text: string }>;
|
||||
backgroundImage: string | undefined;
|
||||
panels: any[];
|
||||
onImageUpload: (image: string) => void;
|
||||
onCropComplete: (croppedImage: string) => void;
|
||||
onCropCancel: () => void;
|
||||
onTextAdd: (text: string) => void;
|
||||
onTextUpdate: (id: string, newText: string) => void;
|
||||
onTextDelete: (id: string) => void;
|
||||
onUploadNewImage: () => void;
|
||||
onDownload: (panel: any, konvaStage: any) => Promise<void>;
|
||||
onDownloadAll: () => Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
uploadedImage,
|
||||
texts,
|
||||
backgroundImage,
|
||||
panels,
|
||||
onImageUpload,
|
||||
onCropComplete,
|
||||
onCropCancel,
|
||||
onTextAdd,
|
||||
onTextUpdate,
|
||||
onTextDelete,
|
||||
onUploadNewImage,
|
||||
onDownload,
|
||||
onDownloadAll,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="app-content">
|
||||
<MainSection
|
||||
{uploadedImage}
|
||||
{texts}
|
||||
{onImageUpload}
|
||||
{onCropComplete}
|
||||
{onCropCancel}
|
||||
{onTextAdd}
|
||||
{onTextUpdate}
|
||||
{onTextDelete}
|
||||
/>
|
||||
<Sidebar {backgroundImage} {panels} {onUploadNewImage} {onDownload} {onDownloadAll} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.app-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,30 +1,86 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
onUploadNewImage?: () => void;
|
||||
}
|
||||
|
||||
let { onUploadNewImage }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="app-header">
|
||||
<h1>Twitch Panels Creator</h1>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
title: string | Snippet;
|
||||
children: Snippet;
|
||||
titleSnippet?: Snippet;
|
||||
}
|
||||
|
||||
let { title, children, titleSnippet = emptySnippet }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#snippet emptySnippet()}{/snippet}
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header-row">
|
||||
<h2 class="card-title">
|
||||
{#if typeof title === "string"}
|
||||
{title}
|
||||
{:else}
|
||||
{@render title()}
|
||||
{/if}
|
||||
</h2>
|
||||
<div class="card-snippet">
|
||||
{@render titleSnippet()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{@render children()}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
background: var(--surface-elevated);
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
transition: var(--transition);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-snippet {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="input-group">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,42 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ImageManager from "$components/image/ImageManager.svelte";
|
||||
import TextSection from "$components/text/TextSection.svelte";
|
||||
import { uiStore } from "$stores/uiStore";
|
||||
|
||||
interface Props {
|
||||
uploadedImage: string | undefined;
|
||||
texts: Array<{ id: string; text: string }>;
|
||||
onImageUpload: (image: string) => void;
|
||||
onCropComplete: (croppedImage: string) => void;
|
||||
onCropCancel: () => void;
|
||||
onTextAdd: (text: string) => void;
|
||||
onTextUpdate: (id: string, newText: string) => void;
|
||||
onTextDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
uploadedImage,
|
||||
texts,
|
||||
onImageUpload,
|
||||
onCropComplete,
|
||||
onCropCancel,
|
||||
onTextAdd,
|
||||
onTextUpdate,
|
||||
onTextDelete,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="main-section">
|
||||
<ImageManager currentStep={$uiStore.currentStep} {uploadedImage} {onImageUpload} {onCropComplete} {onCropCancel} />
|
||||
{#if $uiStore.currentStep === "text"}
|
||||
<TextSection {texts} {onTextAdd} {onTextUpdate} {onTextDelete} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.main-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import ImageManager from "$components/image/ImageManager.svelte";
|
||||
import PreviewManager from "$components/panel/PreviewManager.svelte";
|
||||
</script>
|
||||
|
||||
<div class="panel-bar">
|
||||
<ImageManager />
|
||||
<PreviewManager />
|
||||
</div>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import type { Component, Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
label: string;
|
||||
icon: Component;
|
||||
}
|
||||
|
||||
let { children, label = "fuf", icon }: Props = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="settings-grid">
|
||||
<label>
|
||||
{label}
|
||||
<Button {icon} ariaLabel="Expand" type="mini" onclick={() => (expanded = !expanded)} />
|
||||
</label>
|
||||
{#if expanded}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--surface-subtle);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border-main);
|
||||
}
|
||||
|
||||
label {
|
||||
color: var(--text-main);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
children: Snippet;
|
||||
noLabel?: boolean;
|
||||
}
|
||||
|
||||
let { label, children, noLabel = false }: Props = $props();
|
||||
|
||||
let tag = $derived(noLabel ? "div" : "label");
|
||||
</script>
|
||||
|
||||
<svelte:element this={tag} class="setting-row">
|
||||
<div class="setting-label">{label}</div>
|
||||
<div class="setting-control">
|
||||
{@render children()}
|
||||
</div>
|
||||
</svelte:element>
|
||||
|
||||
<style>
|
||||
.setting-row {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.setting-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script lang="ts">
|
||||
import BackgroundPreview from "$components/image/BackgroundPreview.svelte";
|
||||
import PanelsList from "$components/panel/PanelsList.svelte";
|
||||
import { uiStore } from "$stores/uiStore";
|
||||
|
||||
interface Props {
|
||||
backgroundImage: string | undefined;
|
||||
panels: any[];
|
||||
onUploadNewImage: () => void;
|
||||
onDownload: (panel: any, konvaStage: any) => Promise<void>;
|
||||
onDownloadAll: () => Promise<void>;
|
||||
}
|
||||
|
||||
let { backgroundImage, panels, onUploadNewImage, onDownload, onDownloadAll }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="sidebar">
|
||||
{#if $uiStore.currentStep === "text"}
|
||||
<BackgroundPreview {backgroundImage} {onUploadNewImage} />
|
||||
<PanelsList {panels} {onDownload} {onDownloadAll} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import TextConfig from "$components/text/TextConfig.svelte";
|
||||
import TextManager from "$components/text/TextManager.svelte";
|
||||
</script>
|
||||
|
||||
<div class="text-bar">
|
||||
<TextManager />
|
||||
<TextConfig />
|
||||
</div>
|
||||
@@ -1,173 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import { PANEL_SETTINGS, TYPOGRAPHY, UI_SETTINGS } from "$lib/constants";
|
||||
import type { Panel, TextItem } from "$lib/types/panel";
|
||||
import { setCurrentStep } from "$stores/uiStore";
|
||||
import type { Stage as KonvaStage } from "konva/lib/Stage";
|
||||
import { Image, Layer, Stage, Text } from "svelte-konva";
|
||||
|
||||
interface Props {
|
||||
panel: Panel;
|
||||
onDownload?: (panel: Panel, konvaStage: KonvaStage) => void;
|
||||
}
|
||||
|
||||
let { panel, onDownload }: Props = $props();
|
||||
|
||||
let backgroundImage: HTMLImageElement | undefined = $state(undefined);
|
||||
let konvaStage: KonvaStage | null = $state(null);
|
||||
let stageComponent: any = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
if (stageComponent) {
|
||||
konvaStage = stageComponent.node;
|
||||
}
|
||||
});
|
||||
|
||||
function handleUploadNewImage() {
|
||||
setCurrentStep(UI_SETTINGS.STEPS.UPLOAD);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (panel?.backgroundImage && panel.backgroundImage !== backgroundImage?.src) {
|
||||
loadImage(panel.backgroundImage);
|
||||
}
|
||||
});
|
||||
|
||||
function loadImage(src: string) {
|
||||
const img = document.createElement("img");
|
||||
img.onload = () => {
|
||||
backgroundImage = img;
|
||||
};
|
||||
img.onerror = (error) => {
|
||||
console.error("[PanelPreview] Failed to load background image");
|
||||
};
|
||||
img.src = src;
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
let stageToUse = konvaStage;
|
||||
|
||||
if (!stageToUse && stageComponent) {
|
||||
stageToUse = stageComponent.node || stageComponent.stage || stageComponent._stage || stageComponent;
|
||||
|
||||
if (stageToUse && typeof stageToUse.toBlob === "function") {
|
||||
} else {
|
||||
if (typeof stageComponent.toBlob === "function") {
|
||||
stageToUse = stageComponent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (onDownload && stageToUse) {
|
||||
onDownload(panel, stageToUse);
|
||||
}
|
||||
}
|
||||
|
||||
function getTextPosition(textItem: TextItem) {
|
||||
const panelWidth = PANEL_SETTINGS.PANEL_WIDTH;
|
||||
const paddingX = textItem.paddingX ?? 20;
|
||||
const verticalOffset = textItem.verticalOffset ?? 0;
|
||||
const centerY = panel.height / 2 + verticalOffset;
|
||||
const textWidth = 300;
|
||||
|
||||
let position;
|
||||
switch (textItem.textAlign) {
|
||||
case "left":
|
||||
position = { x: paddingX, y: centerY };
|
||||
break;
|
||||
case "right":
|
||||
position = { x: panelWidth - textWidth - paddingX, y: centerY };
|
||||
break;
|
||||
case "center":
|
||||
default:
|
||||
position = { x: (panelWidth - textWidth) / 2, y: centerY };
|
||||
break;
|
||||
}
|
||||
|
||||
return position;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="panel-preview">
|
||||
<div class="preview-header">
|
||||
<div class="panel-title">{panel.text?.text || "Без названия"}</div>
|
||||
<Button variant="primary" size="sm" onclick={handleDownload}>Скачать</Button>
|
||||
</div>
|
||||
<div class="preview-sections">
|
||||
<div class="preview-section">
|
||||
<div class="canvas-container">
|
||||
<Stage width={PANEL_SETTINGS.PANEL_WIDTH} height={panel.height} bind:this={stageComponent}>
|
||||
<Layer>
|
||||
{#if backgroundImage}
|
||||
<Image image={backgroundImage} width={PANEL_SETTINGS.PANEL_WIDTH} height={panel.height} />
|
||||
{/if}
|
||||
</Layer>
|
||||
|
||||
<Layer>
|
||||
{#if panel.text}
|
||||
{@const textPosition = getTextPosition(panel.text)}
|
||||
|
||||
<Text
|
||||
text={panel.text.text}
|
||||
fontSize={panel.text.fontSize || TYPOGRAPHY.FONT_SIZE_DEFAULT}
|
||||
fill={panel.text.color || TYPOGRAPHY.TEXT_COLOR_DEFAULT}
|
||||
fontFamily={panel.text.fontFamily || "Arial"}
|
||||
x={textPosition.x}
|
||||
y={textPosition.y}
|
||||
align={panel.text.textAlign || TYPOGRAPHY.TEXT_ALIGN_CENTER}
|
||||
width={PANEL_SETTINGS.PANEL_WIDTH - textPosition.x * 2}
|
||||
offsetX={0}
|
||||
/>
|
||||
{/if}
|
||||
</Layer>
|
||||
</Stage>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.panel-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.preview-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,66 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Panel } from "$lib/types/panel";
|
||||
import type { Stage as KonvaStage } from "konva/lib/Stage";
|
||||
import Button from "../ui/Button.svelte";
|
||||
import PanelPreview from "./PanelPreview.svelte";
|
||||
|
||||
interface Props {
|
||||
panels: Panel[];
|
||||
onDownload: (panel: Panel, konvaStage: KonvaStage) => void;
|
||||
onDownloadAll: () => void;
|
||||
}
|
||||
let { panels, onDownload, onDownloadAll }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if panels.length > 0}
|
||||
<div class="panels-container">
|
||||
<div class="panels-header">
|
||||
<h2>Созданные панели ({panels.length})</h2>
|
||||
<Button variant="primary" onclick={onDownloadAll}>Скачать все</Button>
|
||||
</div>
|
||||
<div class="panels-list">
|
||||
{#each panels as panel (panel.id)}
|
||||
<div class="panel-item">
|
||||
<PanelPreview {panel} onDownload={(panel, konvaStage) => onDownload(panel, konvaStage)} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.panels-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.panels-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.panels-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.panels-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.panel-item {
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { PANEL_SETTINGS } from "$lib/constants";
|
||||
import { imageState } from "$states/image.svelte";
|
||||
import { textConfigState } from "$states/textConfig.svelte";
|
||||
import { Image, Layer, Stage, Text } from "svelte-konva";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
stage: Stage | undefined;
|
||||
}
|
||||
|
||||
let { text, stage = $bindable() }: Props = $props();
|
||||
|
||||
let x = $derived(textConfigState.paddingX);
|
||||
let y = $derived(textConfigState.offsetY);
|
||||
let width = $derived(PANEL_SETTINGS.PANEL_WIDTH - 2 * textConfigState.paddingX);
|
||||
</script>
|
||||
|
||||
<Stage
|
||||
width={PANEL_SETTINGS.PANEL_WIDTH}
|
||||
height={PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT}
|
||||
bind:this={stage}
|
||||
>
|
||||
<Layer>
|
||||
<Image image={imageState.masterImage} listening={false} />
|
||||
<Text
|
||||
{text}
|
||||
{x}
|
||||
{y}
|
||||
{width}
|
||||
fontSize={textConfigState.fontSize}
|
||||
stroke={textConfigState.outlined ? textConfigState.color : "transparent"}
|
||||
fill={textConfigState.outlined ? "transparent" : textConfigState.color}
|
||||
fontFamily={textConfigState.fontFamily}
|
||||
align={textConfigState.align}
|
||||
/>
|
||||
</Layer>
|
||||
</Stage>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { konvaAllStagesState } from "$states/konvaAllStages.svelte";
|
||||
import { textsState } from "$states/texts.svelte";
|
||||
|
||||
import Preview from "./Preview.svelte";
|
||||
</script>
|
||||
|
||||
<div class="outside-display">
|
||||
{#each textsState.texts as { text, id }, idx (id)}
|
||||
<Preview {text} bind:stage={konvaAllStagesState[idx]} />
|
||||
{:else}
|
||||
<p>No texts to preview</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.outside-display {
|
||||
position: fixed;
|
||||
top: -1000px;
|
||||
left: -1000px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import { SlideDirection, type SlideDirectionType } from "$lib/constants";
|
||||
import ChevronLeft from "~icons/lucide/chevron-left";
|
||||
import ChevronRight from "~icons/lucide/chevron-right";
|
||||
|
||||
interface Props {
|
||||
current: number;
|
||||
direction: SlideDirectionType;
|
||||
max: number;
|
||||
}
|
||||
|
||||
let { current = $bindable(), direction = $bindable(SlideDirection.NEXT), max }: Props = $props();
|
||||
|
||||
let isFirst = $derived(current === 0);
|
||||
let isLast = $derived(current === max - 1);
|
||||
|
||||
function prev() {
|
||||
if (current > 0) current--;
|
||||
direction = SlideDirection.PREV;
|
||||
}
|
||||
|
||||
async function next() {
|
||||
if (current < max - 1) current++;
|
||||
direction = SlideDirection.NEXT;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button
|
||||
icon={ChevronLeft}
|
||||
ariaLabel="Previous slide"
|
||||
type="mini"
|
||||
onclick={prev}
|
||||
disabled={isFirst}
|
||||
/>
|
||||
<span class="panel-indicator">{current + 1} / {max}</span>
|
||||
<Button icon={ChevronRight} ariaLabel="Next slide" type="mini" onclick={next} disabled={isLast} />
|
||||
|
||||
<style>
|
||||
.panel-indicator {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,174 @@
|
||||
<script lang="ts">
|
||||
import Card from "$components/layout/Card.svelte";
|
||||
import Badge from "$components/ui/Badge.svelte";
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import { PANEL_SETTINGS, TRANSITION_DURATION, type SlideDirectionType } from "$lib/constants";
|
||||
import { downloadService, type DownloadItem } from "$services/downloadService";
|
||||
import { imageState } from "$states/image.svelte";
|
||||
import { konvaAllStagesState } from "$states/konvaAllStages.svelte";
|
||||
import { konvaStageState } from "$states/konvaStage.svelte";
|
||||
import { textsState } from "$states/texts.svelte";
|
||||
import { fly } from "svelte/transition";
|
||||
import Download from "~icons/lucide/download";
|
||||
import Loader from "~icons/lucide/loader";
|
||||
import StickyNote from "~icons/lucide/sticky-note";
|
||||
import Preview from "./Preview.svelte";
|
||||
import PreviewAll from "./PreviewAll.svelte";
|
||||
import PreviewControls from "./PreviewControls.svelte";
|
||||
|
||||
let current: number = $state(0);
|
||||
let direction: SlideDirectionType = $state("next");
|
||||
|
||||
let xDirection = $derived(
|
||||
direction == "next" ? PANEL_SETTINGS.PANEL_WIDTH : -PANEL_SETTINGS.PANEL_WIDTH,
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (textsState.texts.length === 0) {
|
||||
current = 0;
|
||||
} else {
|
||||
if (current > textsState.texts.length - 1) {
|
||||
current = textsState.texts.length - 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function downloadAll() {
|
||||
let downloadItems: Array<DownloadItem> = textsState.texts.map((text, idx) => ({
|
||||
filename: text.text,
|
||||
stage: konvaAllStagesState[idx]?.node,
|
||||
}));
|
||||
|
||||
downloadService.downloadAll(downloadItems);
|
||||
}
|
||||
|
||||
function downloadCurrent() {
|
||||
let node = konvaStageState.stage?.node;
|
||||
if (node) {
|
||||
downloadService.downloadPanel(node, textsState.texts[current].text);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet panelTitle()}
|
||||
Панели <Badge text={textsState.texts.length} />
|
||||
{/snippet}
|
||||
|
||||
{#snippet panelControls()}
|
||||
<PreviewControls bind:current bind:direction max={textsState.texts.length} />
|
||||
{/snippet}
|
||||
|
||||
<Card title={panelTitle} titleSnippet={panelControls}>
|
||||
<div class="panel-viewer">
|
||||
<div class="panel-display">
|
||||
{#if textsState.texts.length}
|
||||
{#key current}
|
||||
{@const text = textsState?.texts[current]?.text}
|
||||
<div
|
||||
class="konva-wrapper"
|
||||
in:fly={{ x: xDirection, duration: TRANSITION_DURATION }}
|
||||
out:fly={{ x: -xDirection, duration: TRANSITION_DURATION }}
|
||||
>
|
||||
<div class="loader" class:loading={imageState.isReady === false}>
|
||||
<Loader />
|
||||
</div>
|
||||
<Preview {text} bind:stage={konvaStageState.stage} />
|
||||
</div>
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="empty-state" aria-label="Empty texts info">
|
||||
<StickyNote />
|
||||
<p>Добавьте тексты для создания панелей</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="panel-actions">
|
||||
<Button label="Скачать всё" ariaLabel="Download all" icon={Download} onclick={downloadAll} />
|
||||
<Button
|
||||
label="Скачать"
|
||||
ariaLabel="Download current"
|
||||
type="secondary"
|
||||
icon={Download}
|
||||
onclick={downloadCurrent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<PreviewAll />
|
||||
|
||||
<style>
|
||||
.panel-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-display {
|
||||
background: var(--surface-subtle);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 150px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.empty-state :global(svg) {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.konva-wrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loader {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: 0.3s;
|
||||
opacity: 0;
|
||||
&.loading {
|
||||
opacity: 1;
|
||||
animation: spin 3s linear infinite;
|
||||
}
|
||||
}
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import Card from "$components/layout/Card.svelte";
|
||||
import SettingsGrid from "$components/layout/SettingsGrid.svelte";
|
||||
import SettingsRow from "$components/layout/SettingsRow.svelte";
|
||||
import Alignment from "$components/ui/Alignment.svelte";
|
||||
import ColorPicker from "$components/ui/ColorPicker.svelte";
|
||||
import Outline from "$components/ui/Outline.svelte";
|
||||
import RangeSlider from "$components/ui/RangeSlider.svelte";
|
||||
import SelectFont from "$components/ui/SelectFont.svelte";
|
||||
import { TYPOGRAPHY } from "$lib/constants";
|
||||
import { textConfigState } from "$states/textConfig.svelte";
|
||||
import TextSettings from "~icons/lucide/text-initial";
|
||||
</script>
|
||||
|
||||
<Card title="Настройки текста">
|
||||
<SettingsGrid label="Настройки шрифта" icon={TextSettings}>
|
||||
<SettingsRow label="Размер">
|
||||
<RangeSlider
|
||||
bind:value={textConfigState.fontSize}
|
||||
min={TYPOGRAPHY.FONT_SIZE_MIN}
|
||||
max={TYPOGRAPHY.FONT_SIZE_MAX}
|
||||
step={1}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Шрифт">
|
||||
<SelectFont bind:value={textConfigState.fontFamily} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Цвет" noLabel={true}>
|
||||
<ColorPicker bind:value={textConfigState.color} />
|
||||
<Outline bind:outlined={textConfigState.outlined} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Выравнивание" noLabel={true}>
|
||||
<Alignment bind:align={textConfigState.align} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Отступы">
|
||||
<RangeSlider
|
||||
bind:value={textConfigState.paddingX}
|
||||
min={TYPOGRAPHY.PADDING_X_MIN}
|
||||
max={TYPOGRAPHY.PADDING_X_MAX}
|
||||
step={1}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Смещение">
|
||||
<RangeSlider
|
||||
bind:value={textConfigState.offsetY}
|
||||
min={TYPOGRAPHY.OFFSET_Y_MIN}
|
||||
max={TYPOGRAPHY.OFFSET_Y_MAX}
|
||||
step={1}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsGrid>
|
||||
</Card>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import { TRANSITION_DURATION } from "$lib/constants";
|
||||
import { fly } from "svelte/transition";
|
||||
import Cross from "~icons/lucide/x";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
id: number;
|
||||
ondelete: (id: number) => void;
|
||||
}
|
||||
|
||||
let { text = $bindable(), id, ondelete }: Props = $props();
|
||||
</script>
|
||||
|
||||
<li class="text-item" transition:fly={{ x: 600, duration: TRANSITION_DURATION }}>
|
||||
<input type="text" bind:value={text} />
|
||||
<Button icon={Cross} ariaLabel="Delete" type="danger" onclick={() => ondelete(id)} />
|
||||
</li>
|
||||
|
||||
<style>
|
||||
.text-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.text-item:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.text-item input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background: var(--surface-main);
|
||||
color: var(--text-main);
|
||||
transition: var(--transition);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.text-item input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
text: string;
|
||||
ariaLabel: string;
|
||||
onenter: () => void;
|
||||
}
|
||||
|
||||
let { text = $bindable(), onenter, ariaLabel }: Props = $props();
|
||||
|
||||
function handleKeyboard(event: KeyboardEvent) {
|
||||
if (event.key === "Enter") {
|
||||
onenter();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<input
|
||||
aria-label={ariaLabel}
|
||||
bind:value={text}
|
||||
class="text-input"
|
||||
type="text"
|
||||
placeholder="Введите текст..."
|
||||
onkeydown={handleKeyboard}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.text-input {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
font-size: 14px;
|
||||
background: var(--surface-main);
|
||||
color: var(--text-main);
|
||||
transition: var(--transition);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.text-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -1,414 +1,40 @@
|
||||
<script lang="ts">
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import { TYPOGRAPHY } from "$lib/constants";
|
||||
import type { TextAlign, TextItem } from "$lib/types/panel";
|
||||
import { textSettingsStore, updateAllTextSettings } from "$stores/panelStore";
|
||||
|
||||
interface Props {
|
||||
onTextAdd: (text: string, settings?: Partial<TextItem>) => void;
|
||||
onTextUpdate: (id: string, text: string) => void;
|
||||
onTextDelete: (id: string) => void;
|
||||
texts: Array<{ id: string; text: string }>;
|
||||
}
|
||||
|
||||
let { onTextAdd, onTextUpdate, onTextDelete, texts }: Props = $props();
|
||||
|
||||
let newText = $state("");
|
||||
let errorMessage = $state<string | undefined>(undefined);
|
||||
|
||||
let commonTextSettings = $derived($textSettingsStore);
|
||||
|
||||
const availableFonts = TYPOGRAPHY.FONT_FAMILIES;
|
||||
|
||||
function handleAddText() {
|
||||
if (!newText.trim()) {
|
||||
errorMessage = "Введите текст";
|
||||
return;
|
||||
}
|
||||
|
||||
if (newText.length > TYPOGRAPHY.MAX_TEXT_LENGTH) {
|
||||
errorMessage = `Текст не должен превышать ${TYPOGRAPHY.MAX_TEXT_LENGTH} символов`;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
errorMessage = undefined;
|
||||
onTextAdd(newText.trim(), commonTextSettings);
|
||||
newText = "";
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : "Ошибка добавления текста";
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyPress(e: KeyboardEvent) {
|
||||
if (e.key === "Enter") {
|
||||
handleAddText();
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdateText(id: string, newText: string) {
|
||||
if (onTextUpdate && newText.trim()) {
|
||||
onTextUpdate(id, newText.trim());
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteText(id: string) {
|
||||
if (onTextDelete) {
|
||||
onTextDelete(id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="text-manager">
|
||||
<div class="add-text-section">
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newText}
|
||||
placeholder="Введите текст для панели (например: links, about me, projects...)"
|
||||
class="text-input"
|
||||
maxlength={TYPOGRAPHY.MAX_TEXT_LENGTH}
|
||||
onkeypress={handleKeyPress}
|
||||
/>
|
||||
<Button variant="primary" onclick={handleAddText}>Добавить</Button>
|
||||
</div>
|
||||
{#if errorMessage}
|
||||
<div class="error-message">
|
||||
{errorMessage}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if texts.length > 0}
|
||||
<div class="texts-list">
|
||||
<h3>Созданные тексты ({texts.length})</h3>
|
||||
<div class="texts-container">
|
||||
{#each texts as textItem (textItem.id)}
|
||||
<div class="text-item">
|
||||
<input
|
||||
type="text"
|
||||
value={textItem.text}
|
||||
oninput={(e) => handleUpdateText(textItem.id, e.currentTarget.value)}
|
||||
class="text-edit-input"
|
||||
maxlength={TYPOGRAPHY.MAX_TEXT_LENGTH}
|
||||
/>
|
||||
<Button variant="danger" size="sm" onclick={() => handleDeleteText(textItem.id)} aria-label="Удалить текст">
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="common-settings-section">
|
||||
<h3>Общие настройки текста</h3>
|
||||
<p class="settings-note">Настройки применятся ко всем создаваемым панелям</p>
|
||||
|
||||
<div class="settings-grid">
|
||||
<div class="control-group">
|
||||
<label>
|
||||
Размер шрифта:
|
||||
<input
|
||||
type="range"
|
||||
min={TYPOGRAPHY.FONT_SIZE_MIN}
|
||||
max={TYPOGRAPHY.FONT_SIZE_MAX}
|
||||
step="1"
|
||||
value={commonTextSettings.fontSize}
|
||||
oninput={(e: Event) => {
|
||||
updateAllTextSettings({ fontSize: parseInt((e.target as HTMLInputElement).value) });
|
||||
}}
|
||||
/>
|
||||
<span class="value-display">{commonTextSettings.fontSize}px</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>
|
||||
Шрифт:
|
||||
<select
|
||||
value={commonTextSettings.fontFamily}
|
||||
onchange={(e) => {
|
||||
updateAllTextSettings({ fontFamily: (e.target as HTMLSelectElement).value });
|
||||
}}
|
||||
>
|
||||
{#each availableFonts as font}
|
||||
<option value={font}>{font}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>
|
||||
Цвет:
|
||||
<input
|
||||
type="color"
|
||||
value={commonTextSettings.color}
|
||||
oninput={(e) => {
|
||||
updateAllTextSettings({ color: (e.target as HTMLInputElement).value });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>
|
||||
Выравнивание:
|
||||
<div class="alignment-buttons">
|
||||
<button
|
||||
class="align-btn {commonTextSettings.textAlign === 'left' ? 'active' : ''}"
|
||||
onclick={() => {
|
||||
updateAllTextSettings({ textAlign: "left" as TextAlign });
|
||||
}}
|
||||
aria-label="Выровнять по левому краю"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
class="align-btn {commonTextSettings.textAlign === 'center' ? 'active' : ''}"
|
||||
onclick={() => {
|
||||
updateAllTextSettings({ textAlign: "center" as TextAlign });
|
||||
}}
|
||||
aria-label="Выровнять по центру"
|
||||
>
|
||||
↔
|
||||
</button>
|
||||
<button
|
||||
class="align-btn {commonTextSettings.textAlign === 'right' ? 'active' : ''}"
|
||||
onclick={() => {
|
||||
updateAllTextSettings({ textAlign: "right" as TextAlign });
|
||||
}}
|
||||
aria-label="Выровнять по правому краю"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>
|
||||
Боковые отступы:
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={TYPOGRAPHY.PADDING_X_MAX}
|
||||
step="1"
|
||||
value={commonTextSettings.paddingX}
|
||||
oninput={(e) => {
|
||||
updateAllTextSettings({ paddingX: parseInt((e.target as HTMLInputElement).value) });
|
||||
}}
|
||||
/>
|
||||
<span class="value-display">{commonTextSettings.paddingX}px</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>
|
||||
Смещение от центра:
|
||||
<input
|
||||
type="range"
|
||||
min={TYPOGRAPHY.VERTICAL_OFFSET_MIN}
|
||||
max={TYPOGRAPHY.VERTICAL_OFFSET_MAX}
|
||||
step="1"
|
||||
value={commonTextSettings.verticalOffset}
|
||||
oninput={(e) => {
|
||||
updateAllTextSettings({ verticalOffset: parseInt((e.target as HTMLInputElement).value) });
|
||||
}}
|
||||
/>
|
||||
<span class="value-display"
|
||||
>{(commonTextSettings.verticalOffset ?? 0) > 0 ? "+" : ""}{commonTextSettings.verticalOffset ?? 0}px</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.text-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.common-settings-section {
|
||||
background: #f8f9fa;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.common-settings-section h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
color: #333;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.settings-note {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.alignment-buttons {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.align-btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.align-btn:hover {
|
||||
background: #f0f0f0;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.align-btn.active {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.add-text-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.texts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.texts-list h3 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.texts-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.text-item {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.text-edit-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.text-edit-input:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
flex: 1;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ffcdd2;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.text-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.text-item:hover {
|
||||
border-color: #007bff;
|
||||
background: #f0f8ff;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.control-group input[type="range"] {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.control-group input[type="color"] {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.value-display {
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
<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 { id }, idx (id)}
|
||||
<TextInlineEdit {id} bind:text={textsState.texts[idx].text} ondelete={() => deleteText(id)} />
|
||||
{/each}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
.texts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { TextItem } from "$lib/types/panel";
|
||||
import TextManager from "./TextManager.svelte";
|
||||
|
||||
interface Props {
|
||||
texts: Array<{ id: string; text: string }>;
|
||||
onTextAdd: (text: string, settings?: Partial<TextItem>) => void;
|
||||
onTextUpdate: (id: string, text: string) => void;
|
||||
onTextDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
let { texts, onTextAdd, onTextUpdate, onTextDelete }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="text-section">
|
||||
<h2>Добавьте тексты для панелей</h2>
|
||||
<TextManager {onTextAdd} {onTextUpdate} {onTextDelete} {texts} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.text-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.text-section h2 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { TextAlign, type TextAlignType } from "$lib/constants";
|
||||
import TextAlignCenter from "~icons/lucide/text-align-center";
|
||||
import TextAlignEnd from "~icons/lucide/text-align-end";
|
||||
import TextAlignStart from "~icons/lucide/text-align-start";
|
||||
|
||||
interface Props {
|
||||
align: TextAlignType;
|
||||
}
|
||||
|
||||
let { align = $bindable(TextAlign.LEFT) }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="alignment-group">
|
||||
<label class="alignment-item">
|
||||
<input
|
||||
type="radio"
|
||||
name="alignment"
|
||||
value={TextAlign.LEFT}
|
||||
class="sr-only"
|
||||
bind:group={align}
|
||||
aria-label={`Set ${TextAlign.LEFT}`}
|
||||
/>
|
||||
<div class="alignment-button"><TextAlignStart /></div>
|
||||
</label>
|
||||
|
||||
<label class="alignment-item">
|
||||
<input
|
||||
type="radio"
|
||||
name="alignment"
|
||||
value={TextAlign.CENTER}
|
||||
class="sr-only"
|
||||
bind:group={align}
|
||||
aria-label={`Set ${TextAlign.CENTER}`}
|
||||
/>
|
||||
<div class="alignment-button"><TextAlignCenter /></div>
|
||||
</label>
|
||||
|
||||
<label class="alignment-item">
|
||||
<input
|
||||
type="radio"
|
||||
name="alignment"
|
||||
value={TextAlign.RIGHT}
|
||||
class="sr-only"
|
||||
bind:group={align}
|
||||
aria-label={`Set ${TextAlign.RIGHT}`}
|
||||
/>
|
||||
<div class="alignment-button"><TextAlignEnd /></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.alignment-group {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.alignment-button {
|
||||
flex-grow: 1;
|
||||
border: 1px solid var(--border-strong);
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.alignment-button:hover {
|
||||
color: var(--text-main);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.alignment-item {
|
||||
flex-grow: 1;
|
||||
& input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
&:first-child .alignment-button {
|
||||
border-top-left-radius: var(--radius);
|
||||
border-bottom-left-radius: var(--radius);
|
||||
}
|
||||
&:last-child .alignment-button {
|
||||
border-top-right-radius: var(--radius);
|
||||
border-bottom-right-radius: var(--radius);
|
||||
}
|
||||
}
|
||||
|
||||
.alignment-item input:checked + .alignment-button {
|
||||
background: var(--action-primary);
|
||||
color: var(--text-action);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
text: string | number;
|
||||
}
|
||||
|
||||
let { text }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span class="badge">{text}</span>
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
background: var(--action-primary);
|
||||
color: var(--text-action);
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
+115
-142
@@ -1,142 +1,115 @@
|
||||
<script lang="ts">
|
||||
type ButtonVariant = "primary" | "secondary" | "danger";
|
||||
type ButtonSize = "sm" | "md" | "lg";
|
||||
type ButtonType = "button" | "submit" | "reset";
|
||||
|
||||
interface Props {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
disabled?: boolean;
|
||||
type?: ButtonType;
|
||||
fullWidth?: boolean;
|
||||
loading?: boolean;
|
||||
class?: string;
|
||||
children?: any;
|
||||
onclick?: (event: MouseEvent) => void;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
disabled = false,
|
||||
type = "button",
|
||||
fullWidth = false,
|
||||
loading = false,
|
||||
class: className = "",
|
||||
children,
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
{type}
|
||||
{disabled}
|
||||
class:btn-primary={variant === "primary"}
|
||||
class:btn-secondary={variant === "secondary"}
|
||||
class:btn-danger={variant === "danger"}
|
||||
class:btn-sm={size === "sm"}
|
||||
class:btn-md={size === "md"}
|
||||
class:btn-lg={size === "lg"}
|
||||
class:btn-full={fullWidth}
|
||||
class:btn-loading={loading}
|
||||
class={className}
|
||||
{...restProps}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="btn-spinner"></span>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.btn-sm {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-md {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.btn-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.btn-primary {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #545b62;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.btn-loading {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn-spinner {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<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" || type === "mini"}
|
||||
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: 1px solid var(--btn-main);
|
||||
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-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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import type { HexColor } from "$lib/types";
|
||||
|
||||
interface Props {
|
||||
value: HexColor;
|
||||
}
|
||||
|
||||
let { value = $bindable() }: Props = $props();
|
||||
</script>
|
||||
|
||||
<input type="color" role="button" bind:value class="color-input" />
|
||||
<span class="color-value">{value}</span>
|
||||
|
||||
<style>
|
||||
.color-input {
|
||||
width: 40px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
background: var(--surface-main);
|
||||
padding: 0px 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.color-input:hover {
|
||||
border-color: var(--action-primary);
|
||||
}
|
||||
|
||||
.color-value {
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 8px;
|
||||
background: var(--surface-main);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,103 +0,0 @@
|
||||
<script lang="ts">
|
||||
type IconButtonVariant = "primary" | "secondary" | "danger";
|
||||
type IconButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
interface Props {
|
||||
variant?: IconButtonVariant;
|
||||
size?: IconButtonSize;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
class?: string;
|
||||
children?: any;
|
||||
onclick?: (event: MouseEvent) => void;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = "secondary",
|
||||
size = "md",
|
||||
disabled = false,
|
||||
ariaLabel = "",
|
||||
class: className = "",
|
||||
children,
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
{disabled}
|
||||
aria-label={ariaLabel}
|
||||
class:icon-btn-primary={variant === "primary"}
|
||||
class:icon-btn-secondary={variant === "secondary"}
|
||||
class:icon-btn-danger={variant === "danger"}
|
||||
class:icon-btn-sm={size === "sm"}
|
||||
class:icon-btn-md={size === "md"}
|
||||
class:icon-btn-lg={size === "lg"}
|
||||
class={className}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.icon-btn-sm {
|
||||
padding: 0.125rem 0.25rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.icon-btn-md {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.icon-btn-lg {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.icon-btn-primary {
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.icon-btn-primary:hover:not(:disabled) {
|
||||
color: #0056b3;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.icon-btn-secondary {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.icon-btn-secondary:hover:not(:disabled) {
|
||||
color: #007bff;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.icon-btn-danger {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.icon-btn-danger:hover:not(:disabled) {
|
||||
color: #c82333;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import TypeOutline from "~icons/lucide/type-outline";
|
||||
import Button from "./Button.svelte";
|
||||
|
||||
interface Props {
|
||||
outlined: boolean;
|
||||
}
|
||||
|
||||
let { outlined = $bindable(false) }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Button
|
||||
icon={TypeOutline}
|
||||
ariaLabel="toggle outline"
|
||||
onclick={() => (outlined = !outlined)}
|
||||
type={outlined ? "outline" : "primary"}
|
||||
/>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import type { ChangeEventHandler } from "svelte/elements";
|
||||
import Reset from "~icons/lucide/rotate-ccw";
|
||||
import Button from "./Button.svelte";
|
||||
|
||||
interface Props {
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
defaultValue?: number;
|
||||
showReset?: boolean;
|
||||
onchange?: ChangeEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(),
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1,
|
||||
onchange = () => {},
|
||||
defaultValue = 0,
|
||||
showReset = false,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<input type="range" {min} {max} {step} bind:value class="slider" {onchange} />
|
||||
<span class="value-display">{value}</span>
|
||||
{#if showReset}
|
||||
<Button ariaLabel="Reset" icon={Reset} type="mini" onclick={() => (value = defaultValue)} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.slider {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--surface-control);
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--action-primary);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--action-primary);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.value-display {
|
||||
color: var(--text-main);
|
||||
font-weight: 500;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
padding: 4px 8px;
|
||||
background: var(--surface-main);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
value: string;
|
||||
}
|
||||
|
||||
let { value = $bindable() }: Props = $props();
|
||||
</script>
|
||||
|
||||
<select class="select-input" bind:value>
|
||||
<option value="Arial">Arial</option>
|
||||
<option value="Verdana">Verdana</option>
|
||||
<option value="Georgia">Georgia</option>
|
||||
<option value="Times New Roman">Times New Roman</option>
|
||||
<option value="Courier New">Courier New</option>
|
||||
<option value="Impact">Impact</option>
|
||||
<option value="Comic Sans MS">Comic Sans MS</option>
|
||||
<option value="Trebuchet MS">Trebuchet MS</option>
|
||||
</select>
|
||||
|
||||
<style>
|
||||
.select-input {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-main);
|
||||
color: var(--text-main);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.select-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
</style>
|
||||
+102
-85
@@ -1,85 +1,102 @@
|
||||
// Application Constants
|
||||
// This file contains magic numbers and strings used in JavaScript/TypeScript logic
|
||||
|
||||
// ===== PANEL SETTINGS =====
|
||||
export const PANEL_SETTINGS = {
|
||||
// Storage
|
||||
STORAGE_KEY: "twitch-panels",
|
||||
MAX_PANELS_COUNT: 50,
|
||||
|
||||
// Dimensions
|
||||
PANEL_WIDTH: 320,
|
||||
PANEL_HEIGHT_DEFAULT: 100,
|
||||
PANEL_HEIGHT_MAX: 1000,
|
||||
|
||||
// Default values
|
||||
DEFAULT_BACKGROUND_IMAGE: "./backgrounds/b1.jpg",
|
||||
} as const;
|
||||
|
||||
// ===== TYPOGRAPHY =====
|
||||
export const TYPOGRAPHY = {
|
||||
// Font families
|
||||
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: 18,
|
||||
|
||||
// Text alignment
|
||||
TEXT_ALIGN_LEFT: "left",
|
||||
TEXT_ALIGN_CENTER: "center",
|
||||
TEXT_ALIGN_RIGHT: "right",
|
||||
|
||||
// Text limits
|
||||
MAX_TEXT_LENGTH: 100,
|
||||
|
||||
// Padding for range inputs
|
||||
PADDING_X_DEFAULT: 10,
|
||||
PADDING_X_LARGE: 20,
|
||||
PADDING_X_MAX: 50,
|
||||
|
||||
// Vertical offset for range inputs
|
||||
VERTICAL_OFFSET_MAX: 50,
|
||||
VERTICAL_OFFSET_MIN: -50,
|
||||
|
||||
// Colors
|
||||
TEXT_COLOR_DEFAULT: "#ffffff",
|
||||
TEXT_COLOR_ERROR: "#c62828",
|
||||
} as const;
|
||||
|
||||
// ===== IMAGE SETTINGS =====
|
||||
export const IMAGE_SETTINGS = {
|
||||
// File sizes
|
||||
MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB
|
||||
|
||||
// Supported formats
|
||||
SUPPORTED_FORMATS: ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"] as const,
|
||||
} as const;
|
||||
|
||||
// ===== UI SETTINGS =====
|
||||
export const UI_SETTINGS = {
|
||||
// Steps
|
||||
STEPS: {
|
||||
UPLOAD: "upload",
|
||||
CROP: "crop",
|
||||
TEXT: "text",
|
||||
} as const,
|
||||
} as const;
|
||||
|
||||
// ===== ERROR HANDLING =====
|
||||
export const ERROR_HANDLING = {
|
||||
// Retry settings
|
||||
MAX_RETRIES: 3,
|
||||
RETRY_DELAY_MS: 1000,
|
||||
} as const;
|
||||
// Application Constants
|
||||
|
||||
// ===== PANEL SETTINGS =====
|
||||
export const PANEL_SETTINGS = {
|
||||
PANEL_WIDTH: 320,
|
||||
PANEL_HEIGHT_DEFAULT: 100,
|
||||
PANEL_HEIGHT_MAX: 200,
|
||||
} 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_MIN: 0,
|
||||
PADDING_X_MAX: 100,
|
||||
|
||||
// Vertical offset for range inputs
|
||||
OFFSET_Y_MAX: 100,
|
||||
OFFSET_Y_MIN: -100,
|
||||
OFFSET_Y_DEFAULT: 0,
|
||||
|
||||
// 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,
|
||||
|
||||
DEFAULT_BACKGROUND_IMAGE: "./backgrounds/b1.jpg",
|
||||
|
||||
HUE_DEFAULT: 0,
|
||||
HUE_MIN: 0,
|
||||
HUE_MAX: 360,
|
||||
|
||||
SATURATION_DEFAULT: 0,
|
||||
SATURATION_MIN: -20,
|
||||
SATURATION_MAX: 50,
|
||||
SATURATION_DIVIDER: 10,
|
||||
|
||||
LUMINANCE_DEFAULT: 0,
|
||||
LUMINANCE_MIN: -100,
|
||||
LUMINANCE_MAX: 100,
|
||||
LUMINANCE_DIVIDER: 100,
|
||||
|
||||
BRIGHTNESS_DEFAULT: 100,
|
||||
BRIGHTNESS_MIN: 0,
|
||||
BRIGHTNESS_MAX: 200,
|
||||
BRIGHTNESS_DIVIDER: 100,
|
||||
|
||||
CONTRAST_DEFAULT: 0,
|
||||
CONTRAST_MIN: -100,
|
||||
CONTRAST_MAX: 100,
|
||||
} as const;
|
||||
|
||||
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 TEXT_ALIGN_DEFAULT: TextAlignType = TextAlign.CENTER;
|
||||
|
||||
export const Theme = {
|
||||
DARK: "dark",
|
||||
LIGHT: "light",
|
||||
} as const;
|
||||
|
||||
export type ThemeType = (typeof Theme)[keyof typeof Theme];
|
||||
|
||||
export const TRANSITION_DURATION = import.meta.env.MODE === "test" ? 0 : 300;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AppError";
|
||||
if (details) this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export class ImageError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "IMAGE_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
export class TextError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "TEXT_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
export class CanvasError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "CANVAS_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
export class StorageError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "STORAGE_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
export type ErrorType = AppError | ImageError | TextError | CanvasError | StorageError;
|
||||
@@ -1,30 +0,0 @@
|
||||
import { building } from "$app/environment";
|
||||
import { readdirSync } from "node:fs";
|
||||
import { join } from "path";
|
||||
|
||||
export function loadFonts() {
|
||||
try {
|
||||
const fontsDir = join(process.cwd(), "static", "fonts");
|
||||
const files = readdirSync(fontsDir);
|
||||
|
||||
let fonts = files
|
||||
.filter((file) => /\.(woff2|woff|ttf|otf)$/i.test(file))
|
||||
.map((file) => ({
|
||||
name: file.replace(/\.[^/.]+$/, ""),
|
||||
file,
|
||||
url: `/fonts/${file}`,
|
||||
format: getFormat(file),
|
||||
}));
|
||||
|
||||
return fonts;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getFormat(filename: string) {
|
||||
if (filename.endsWith(".woff2")) return "woff2";
|
||||
if (filename.endsWith(".woff")) return "woff";
|
||||
if (filename.endsWith(".ttf")) return "truetype";
|
||||
return "opentype";
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { building } from "$app/environment";
|
||||
import { readdirSync } from "node:fs";
|
||||
import { join } from "path";
|
||||
|
||||
export function loadImages() {
|
||||
try {
|
||||
const imagesDir = join(process.cwd(), "static", "backgrounds");
|
||||
const files = readdirSync(imagesDir);
|
||||
|
||||
let fonts = files.filter((file) => /\.(png|jpg)$/i.test(file));
|
||||
|
||||
return fonts;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type HexColor = `#${string}`;
|
||||
@@ -1,37 +0,0 @@
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public recoverable: boolean = true,
|
||||
public details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AppError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ImageError extends AppError {
|
||||
constructor(message: string, recoverable: boolean = true) {
|
||||
super(message, "IMAGE_ERROR", recoverable);
|
||||
}
|
||||
}
|
||||
|
||||
export class TextError extends AppError {
|
||||
constructor(message: string, recoverable: boolean = true) {
|
||||
super(message, "TEXT_ERROR", recoverable);
|
||||
}
|
||||
}
|
||||
|
||||
export class CanvasError extends AppError {
|
||||
constructor(message: string, recoverable: boolean = true) {
|
||||
super(message, "CANVAS_ERROR", recoverable);
|
||||
}
|
||||
}
|
||||
|
||||
export class StorageError extends AppError {
|
||||
constructor(message: string, recoverable: boolean = true) {
|
||||
super(message, "STORAGE_ERROR", recoverable);
|
||||
}
|
||||
}
|
||||
|
||||
export type ErrorType = AppError | ImageError | TextError | CanvasError | StorageError;
|
||||
@@ -1,48 +0,0 @@
|
||||
export type TextAlign = "left" | "center" | "right";
|
||||
|
||||
export interface TextItem {
|
||||
id: string;
|
||||
text: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
color: string;
|
||||
textAlign: TextAlign;
|
||||
paddingX: number;
|
||||
verticalOffset: number;
|
||||
}
|
||||
|
||||
export interface Panel {
|
||||
id: string;
|
||||
backgroundImage: string;
|
||||
text: TextItem;
|
||||
height: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ImageUploadResult {
|
||||
success: boolean;
|
||||
image?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CropOptions {
|
||||
width: number;
|
||||
height: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
}
|
||||
|
||||
export interface ImageCropResult {
|
||||
success: boolean;
|
||||
croppedImage?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UIState {
|
||||
isLoading: boolean;
|
||||
error: string | undefined;
|
||||
currentStep: "upload" | "crop" | "text" | "preview";
|
||||
showCropModal: boolean;
|
||||
showTextManager: boolean;
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { ERROR_HANDLING } from "../constants";
|
||||
import { AppError } from "../types/errors";
|
||||
|
||||
export function handleError(error: unknown, defaultMessage: string = "Произошла ошибка"): string {
|
||||
if (error instanceof AppError) {
|
||||
return error.recoverable ? `Ошибка: ${error.message}. Попробуйте снова.` : `Критическая ошибка: ${error.message}`;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return `${defaultMessage}: ${error.message}`;
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
return `${defaultMessage}: ${error}`;
|
||||
}
|
||||
|
||||
return `${defaultMessage}: Произошла неизвестная ошибка`;
|
||||
}
|
||||
|
||||
export function isRecoverableError(error: unknown): boolean {
|
||||
if (error instanceof AppError) {
|
||||
return error.recoverable;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function createError(message: string, code: string, recoverable: boolean = true, details?: unknown): AppError {
|
||||
return new AppError(message, code, recoverable, details);
|
||||
}
|
||||
|
||||
export function logError(error: unknown, context?: string): void {
|
||||
console.error("Error occurred:", {
|
||||
error,
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function retryOperation<T>(
|
||||
operation: () => Promise<T>,
|
||||
maxRetries: number = ERROR_HANDLING.MAX_RETRIES,
|
||||
delayMs: number = ERROR_HANDLING.RETRY_DELAY_MS,
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let attempt = 0;
|
||||
|
||||
const attemptOperation = () => {
|
||||
attempt++;
|
||||
operation()
|
||||
.then(resolve)
|
||||
.catch((error) => {
|
||||
if (attempt >= maxRetries) {
|
||||
reject(error);
|
||||
} else {
|
||||
setTimeout(attemptOperation, delayMs * attempt);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
attemptOperation();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { AppError } from "$lib/error.types";
|
||||
|
||||
export function formatError(error: unknown, defaultMessage: string = "Произошла ошибка"): string {
|
||||
if (error instanceof AppError || error instanceof Error) {
|
||||
return `${defaultMessage}: ${error.message}`;
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
return `${defaultMessage}: ${error}`;
|
||||
}
|
||||
|
||||
return `${defaultMessage}: Произошла неизвестная ошибка`;
|
||||
}
|
||||
|
||||
export function createError(message: string, code: string, details?: unknown): AppError {
|
||||
return new AppError(message, code, details);
|
||||
}
|
||||
|
||||
export function logError(error: unknown, context?: string): void {
|
||||
console.error("Error occurred:", {
|
||||
error,
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { IMAGE_SETTINGS } from "../constants";
|
||||
import { ImageError } from "../types/errors";
|
||||
|
||||
export const MAX_FILE_SIZE = IMAGE_SETTINGS.MAX_FILE_SIZE;
|
||||
export const SUPPORTED_FORMATS = IMAGE_SETTINGS.SUPPORTED_FORMATS;
|
||||
|
||||
export type ValidationResult =
|
||||
| {
|
||||
isValid: true;
|
||||
}
|
||||
| {
|
||||
isValid: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export function validateFileSize(file: File): ValidationResult {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Файл слишком большой. Максимальный размер: ${MAX_FILE_SIZE / 1024 / 1024}MB`,
|
||||
};
|
||||
}
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
export function validateFileType(file: File): ValidationResult {
|
||||
if (!SUPPORTED_FORMATS.includes(file.type as any)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Неподдерживаемый формат. Допустимые форматы: ${SUPPORTED_FORMATS.join(", ")}`,
|
||||
};
|
||||
}
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
export function validateImageFile(file: File): ValidationResult {
|
||||
const sizeValidation = validateFileSize(file);
|
||||
if (!sizeValidation.isValid) {
|
||||
return sizeValidation;
|
||||
}
|
||||
|
||||
const typeValidation = validateFileType(file);
|
||||
if (!typeValidation.isValid) {
|
||||
return typeValidation;
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
export function validateImageUrl(url: string): ValidationResult {
|
||||
try {
|
||||
new URL(url);
|
||||
return { isValid: true };
|
||||
} catch {
|
||||
return {
|
||||
isValid: false,
|
||||
error: "Неверный URL формат",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function validateImageElement(img: HTMLImageElement): ValidationResult {
|
||||
if (!img.naturalWidth || !img.naturalHeight) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: "Изображение не может быть загружено или повреждено",
|
||||
};
|
||||
}
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
export async function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = document.createElement("img");
|
||||
img.crossOrigin = "anonymous";
|
||||
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new ImageError("Не удалось загрузить изображение"));
|
||||
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
export function getImageDimensions(img: HTMLImageElement): { width: number; height: number } {
|
||||
return {
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateAspectRatioFit(
|
||||
srcWidth: number,
|
||||
srcHeight: number,
|
||||
maxWidth: number,
|
||||
maxHeight: number,
|
||||
): { width: number; height: number } {
|
||||
const ratio = Math.min(maxWidth / srcWidth, maxHeight / srcHeight);
|
||||
return {
|
||||
width: srcWidth * ratio,
|
||||
height: srcHeight * ratio,
|
||||
};
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { PANEL_SETTINGS } from "../constants";
|
||||
import type { Panel } from "../types/panel";
|
||||
import { handleError, logError } from "./errorHandler";
|
||||
|
||||
const STORAGE_KEY = PANEL_SETTINGS.STORAGE_KEY;
|
||||
const MAX_PANELS = PANEL_SETTINGS.MAX_PANELS_COUNT;
|
||||
|
||||
export interface StorageResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class PanelStorage {
|
||||
savePanel(panel: Panel): StorageResult {
|
||||
try {
|
||||
const panels = this.getAllPanels();
|
||||
|
||||
const existingIndex = panels.findIndex((p) => p.id === panel.id);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
panels[existingIndex] = panel;
|
||||
} else {
|
||||
panels.unshift(panel);
|
||||
|
||||
if (panels.length > MAX_PANELS) {
|
||||
panels.length = MAX_PANELS;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(panels));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logError(error, "Failed to save panel");
|
||||
return {
|
||||
success: false,
|
||||
error: handleError(error, "Ошибка сохранения панели"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getAllPanels(): Panel[] {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const panels: Panel[] = JSON.parse(data);
|
||||
|
||||
// Convert date strings back to Date objects and validate
|
||||
return panels
|
||||
.map((panel) => ({
|
||||
...panel,
|
||||
createdAt: new Date(panel.createdAt),
|
||||
updatedAt: new Date(panel.updatedAt),
|
||||
}))
|
||||
.filter((panel) => this.validatePanel(panel));
|
||||
} catch (error) {
|
||||
logError(error, "Failed to load panels");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
getPanelById(id: string): Panel | undefined {
|
||||
try {
|
||||
const panels = this.getAllPanels();
|
||||
return panels.find((p) => p.id === id) || undefined;
|
||||
} catch (error) {
|
||||
logError(error, "Failed to get panel by id");
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
deletePanel(id: string): StorageResult {
|
||||
try {
|
||||
const panels = this.getAllPanels();
|
||||
const filteredPanels = panels.filter((p) => p.id !== id);
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(filteredPanels));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logError(error, "Failed to delete panel");
|
||||
return {
|
||||
success: false,
|
||||
error: handleError(error, "Ошибка удаления панели"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
clearAll(): StorageResult {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logError(error, "Failed to clear panels");
|
||||
return {
|
||||
success: false,
|
||||
error: handleError(error, "Ошибка очистки хранилища"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private validatePanel(panel: any): panel is Panel {
|
||||
return (
|
||||
typeof panel === "object" &&
|
||||
typeof panel.id === "string" &&
|
||||
typeof panel.backgroundImage === "string" &&
|
||||
typeof panel.text === "object" &&
|
||||
typeof panel.text?.id === "string" &&
|
||||
typeof panel.text?.text === "string" &&
|
||||
typeof panel.height === "number" &&
|
||||
panel.height > 0 &&
|
||||
panel.height <= PANEL_SETTINGS.PANEL_HEIGHT_MAX &&
|
||||
panel.createdAt instanceof Date &&
|
||||
panel.updatedAt instanceof Date
|
||||
);
|
||||
}
|
||||
|
||||
getPanelCount(): number {
|
||||
return this.getAllPanels().length;
|
||||
}
|
||||
|
||||
hasSpaceForNewPanel(): boolean {
|
||||
return this.getPanelCount() < MAX_PANELS;
|
||||
}
|
||||
}
|
||||
|
||||
export const panelStorage = new PanelStorage();
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import AppHeader from "$components/layout/AppHeader.svelte";
|
||||
import { IMAGE_SETTINGS } from "$lib/constants";
|
||||
import { uploadService } from "$services/uploadService";
|
||||
import { imageState } from "$states/image.svelte";
|
||||
import { themeState } from "$states/theme.svelte";
|
||||
import { onMount, type Snippet } from "svelte";
|
||||
import "../app.css";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const newTheme = themeState.current;
|
||||
document.documentElement.setAttribute("data-theme", newTheme);
|
||||
});
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
onMount(async () => {
|
||||
let defaultImage = await uploadService.fromUrl(IMAGE_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
|
||||
if (defaultImage.ok) {
|
||||
imageState.fullImage = defaultImage.data;
|
||||
} else {
|
||||
console.error("Failed to load default image");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<AppHeader />
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,2 +1,2 @@
|
||||
export const ssr = false;
|
||||
export const prerender = true;
|
||||
export const ssr = false;
|
||||
export const prerender = true;
|
||||
|
||||
+23
-121
@@ -1,121 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { Panel, TextItem } from "$lib/types/panel";
|
||||
import type { Stage as KonvaStage } from "konva/lib/Stage";
|
||||
import { onMount } from "svelte";
|
||||
import { exportService } from "../services/exportService";
|
||||
import { imageService } from "../services/imageService";
|
||||
import { panelService } from "../services/panelService";
|
||||
import { textSettingsStore } from "../stores/panelStore";
|
||||
|
||||
import AppContainer from "../components/layout/AppContainer.svelte";
|
||||
|
||||
let uploadedImage = $state<string | undefined>(undefined);
|
||||
let panels = $state<Panel[]>([]);
|
||||
let texts = $state<Array<{ id: string; text: string }>>([]);
|
||||
let backgroundImage = $state<string | undefined>(undefined);
|
||||
|
||||
let textSettings = $derived($textSettingsStore);
|
||||
|
||||
let previousSettings = $state<string>("");
|
||||
|
||||
$effect(() => {
|
||||
const currentSettings = textSettings;
|
||||
const settingsString = JSON.stringify(currentSettings);
|
||||
|
||||
if (settingsString !== previousSettings) {
|
||||
const updatedPanels = panels.map((panel) => ({
|
||||
...panel,
|
||||
text: { ...panel.text, ...currentSettings },
|
||||
}));
|
||||
|
||||
Promise.resolve().then(() => {
|
||||
if (panels.length > 0) {
|
||||
panels = updatedPanels;
|
||||
}
|
||||
});
|
||||
|
||||
previousSettings = settingsString;
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
const defaultTexts = [
|
||||
{ id: crypto.randomUUID(), text: "About" },
|
||||
{ id: crypto.randomUUID(), text: "Links" },
|
||||
];
|
||||
texts = defaultTexts;
|
||||
|
||||
try {
|
||||
const loadedBackground = await imageService.loadDefaultBackground();
|
||||
backgroundImage = loadedBackground;
|
||||
} catch (error) {
|
||||
console.error("Failed to load default background");
|
||||
exportService.setErrorMessage("Не удалось загрузить фоновое изображение по умолчанию");
|
||||
}
|
||||
|
||||
const initialPanels = panelService.updatePanelsFromTexts(texts, [], backgroundImage || "");
|
||||
panels = initialPanels;
|
||||
});
|
||||
|
||||
function handleImageUpload(image: string) {
|
||||
uploadedImage = image;
|
||||
imageService.handleImageUpload(image);
|
||||
}
|
||||
|
||||
function handleUploadNewImage() {
|
||||
imageService.handleUploadNewImage();
|
||||
}
|
||||
|
||||
function handleCropComplete(croppedImage: string) {
|
||||
backgroundImage = croppedImage;
|
||||
uploadedImage = undefined;
|
||||
imageService.handleCropComplete(croppedImage);
|
||||
panels = panelService.updatePanelsBackground(panels, croppedImage);
|
||||
panels = panelService.updatePanelsFromTexts(texts, panels, croppedImage);
|
||||
}
|
||||
|
||||
function handleCropCancel() {
|
||||
uploadedImage = undefined;
|
||||
imageService.handleCropCancel();
|
||||
}
|
||||
|
||||
function handleAddText(text: string, settings?: Partial<TextItem>) {
|
||||
texts = panelService.addText(texts, text);
|
||||
panels = panelService.updatePanelsFromTexts(texts, panels, backgroundImage!, settings);
|
||||
}
|
||||
|
||||
function handleUpdateText(id: string, newText: string) {
|
||||
texts = panelService.updateText(texts, id, newText);
|
||||
panels = panelService.updatePanelsFromTexts(texts, panels, backgroundImage!);
|
||||
}
|
||||
|
||||
function handleDeleteText(id: string) {
|
||||
texts = panelService.deleteText(texts, id);
|
||||
panels = panelService.updatePanelsFromTexts(texts, panels, backgroundImage!);
|
||||
}
|
||||
|
||||
async function handleDownload(panel: Panel, konvaStage: KonvaStage) {
|
||||
await exportService.handleDownload(panel, konvaStage);
|
||||
}
|
||||
|
||||
async function handleDownloadAll() {
|
||||
await exportService.handleDownloadAll(panels);
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppContainer
|
||||
errorMessage={exportService.getErrorMessage() || undefined}
|
||||
{uploadedImage}
|
||||
{texts}
|
||||
{backgroundImage}
|
||||
{panels}
|
||||
onUploadNewImage={handleUploadNewImage}
|
||||
onImageUpload={handleImageUpload}
|
||||
onCropComplete={handleCropComplete}
|
||||
onCropCancel={handleCropCancel}
|
||||
onTextAdd={handleAddText}
|
||||
onTextUpdate={handleUpdateText}
|
||||
onTextDelete={handleDeleteText}
|
||||
onDownload={handleDownload}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ImageError } from "$lib/error.types";
|
||||
import { formatError, logError } from "$lib/utils/errorUtils";
|
||||
import { saveAs } from "file-saver";
|
||||
import JSZip from "jszip";
|
||||
import { Stage } from "konva/lib/Stage";
|
||||
|
||||
export type DownloadResult =
|
||||
| {
|
||||
success: true;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export interface DownloadItem {
|
||||
filename: string;
|
||||
stage: Stage;
|
||||
}
|
||||
|
||||
export class DownloadService {
|
||||
async downloadPanel(konvaStage: Stage, label: string): Promise<DownloadResult> {
|
||||
try {
|
||||
const blob = await this.stageToBlob(konvaStage);
|
||||
|
||||
const filename = `${label}.png`;
|
||||
saveAs(blob, filename);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logError(error, "Ошибка сохранения панели");
|
||||
return {
|
||||
success: false,
|
||||
error: formatError(error, "Ошибка сохранения панели"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async downloadAll(panels: Array<DownloadItem>): Promise<DownloadResult> {
|
||||
try {
|
||||
const zip = new JSZip();
|
||||
for (const panel of panels) {
|
||||
const blob = await this.stageToBlob(panel.stage);
|
||||
zip.file(`${panel.filename}.png`, blob);
|
||||
}
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: "blob" });
|
||||
saveAs(zipBlob, "panels.zip");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logError(error, "Ошибка сохранения архива");
|
||||
return {
|
||||
success: false,
|
||||
error: formatError(error, "Ошибка сохранения архива"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async stageToBlob(konvaStage: Stage): Promise<Blob> {
|
||||
if (!konvaStage || typeof konvaStage.toBlob !== "function") {
|
||||
throw new ImageError("Konva Stage не найден или не поддерживает toBlob");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
konvaStage.toBlob({
|
||||
callback: (blob: Blob | null) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new ImageError("Не удалось создать изображение из Konva Stage"));
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const downloadService = new DownloadService();
|
||||
@@ -1,98 +0,0 @@
|
||||
import pkg from "file-saver";
|
||||
import type { Stage } from "konva/lib/Stage";
|
||||
import { ImageError } from "../lib/types/errors";
|
||||
import type { Panel } from "../lib/types/panel";
|
||||
import { handleError, logError } from "../lib/utils/errorHandler";
|
||||
const { saveAs } = pkg;
|
||||
|
||||
export interface ExportResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class ExportService {
|
||||
async exportPanel(panel: Panel, konvaStage: Stage, filename?: string): Promise<ExportResult> {
|
||||
try {
|
||||
if (!konvaStage) {
|
||||
throw new ImageError("Konva Stage не передан для экспорта");
|
||||
}
|
||||
|
||||
const blob = await this.exportKonvaStageToBlob(konvaStage);
|
||||
|
||||
if (!blob) {
|
||||
throw new ImageError("Не удалось создать изображение");
|
||||
}
|
||||
|
||||
const defaultFilename = `twitch-panel-${panel.id}.png`;
|
||||
saveAs(blob, filename || defaultFilename);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logError(error, "Panel export failed");
|
||||
return {
|
||||
success: false,
|
||||
error: handleError(error, "Ошибка экспорта панели"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async exportKonvaStageToBlob(konvaStage: Stage): Promise<Blob | null> {
|
||||
if (!konvaStage || typeof konvaStage.toBlob !== "function") {
|
||||
throw new ImageError("Konva Stage не найден или не поддерживает toBlob");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
konvaStage.toBlob({
|
||||
callback: (blob: Blob | null) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new ImageError("Не удалось создать изображение из Konva Stage"));
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Экспортирует несколько панелей в ZIP архив
|
||||
*/
|
||||
async exportPanels(panels: Panel[]): Promise<ExportResult> {
|
||||
return {
|
||||
success: false,
|
||||
error: "Пакетный экспорт еще не реализован",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик для скачивания одной панели
|
||||
*/
|
||||
async handleDownload(panel: Panel, konvaStage: Stage): Promise<void> {
|
||||
const result = await this.exportPanel(panel, konvaStage);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Ошибка экспорта панели");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик для скачивания всех панелей
|
||||
*/
|
||||
async handleDownloadAll(panels: Panel[]): Promise<void> {
|
||||
const result = await this.exportPanels(panels);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Ошибка экспорта панелей");
|
||||
}
|
||||
}
|
||||
|
||||
getErrorMessage(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
setErrorMessage(message: string): void {
|
||||
console.error("ExportService error:", message);
|
||||
}
|
||||
}
|
||||
|
||||
export const exportService = new ExportService();
|
||||
@@ -1,130 +0,0 @@
|
||||
import { PANEL_SETTINGS } from "../lib/constants";
|
||||
import { setCurrentStep } from "../stores/uiStore";
|
||||
|
||||
export class ImageService {
|
||||
private static instance: ImageService;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): ImageService {
|
||||
if (!ImageService.instance) {
|
||||
ImageService.instance = new ImageService();
|
||||
}
|
||||
return ImageService.instance;
|
||||
}
|
||||
|
||||
async loadDefaultBackground(): Promise<string> {
|
||||
const defaultBackground = PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE;
|
||||
const response = await fetch(defaultBackground);
|
||||
if (!response.ok) throw new Error("Не удалось загрузить фоновое изображение");
|
||||
const blob = await response.blob();
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
handleImageUpload(image: string): void {
|
||||
setCurrentStep("crop");
|
||||
}
|
||||
|
||||
handleUploadNewImage(): void {
|
||||
setCurrentStep("upload");
|
||||
}
|
||||
|
||||
handleCropComplete(croppedImage: string): void {
|
||||
setCurrentStep("text");
|
||||
}
|
||||
|
||||
handleCropCancel(): void {
|
||||
setCurrentStep("text");
|
||||
}
|
||||
|
||||
async handleFileUpload(file: File): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () =>
|
||||
resolve({
|
||||
success: true,
|
||||
image: reader.result as string,
|
||||
});
|
||||
reader.onerror = () =>
|
||||
reject({
|
||||
success: false,
|
||||
error: "Ошибка чтения файла",
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async handlePasteUpload(pasteEvent: ClipboardEvent): Promise<any> {
|
||||
const items = pasteEvent.clipboardData?.items;
|
||||
if (!items || items.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: "В буфере обмена не найдено изображений",
|
||||
};
|
||||
}
|
||||
|
||||
let imageItem: DataTransferItem | undefined = undefined;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.indexOf("image") !== -1) {
|
||||
imageItem = items[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!imageItem) {
|
||||
return {
|
||||
success: false,
|
||||
error: "В буфере обмена не найдено изображений",
|
||||
};
|
||||
}
|
||||
|
||||
const file = imageItem.getAsFile();
|
||||
if (!file) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Не удалось получить файл из буфера обмена",
|
||||
};
|
||||
}
|
||||
|
||||
return this.handleFileUpload(file);
|
||||
}
|
||||
|
||||
async handleUrlUpload(url: string): Promise<any> {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Не удалось загрузить изображение по URL",
|
||||
};
|
||||
}
|
||||
const blob = await response.blob();
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () =>
|
||||
resolve({
|
||||
success: true,
|
||||
image: reader.result as string,
|
||||
});
|
||||
reader.onerror = () =>
|
||||
reject({
|
||||
success: false,
|
||||
error: "Ошибка чтения изображения",
|
||||
});
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Ошибка загрузки изображения по URL",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const imageService = ImageService.getInstance();
|
||||
@@ -1,71 +0,0 @@
|
||||
import { PANEL_SETTINGS } from "../lib/constants";
|
||||
import type { Panel, TextItem } from "../lib/types/panel";
|
||||
import { createPanelFromText, updatePanelText } from "../stores/panelStore";
|
||||
|
||||
export class PanelService {
|
||||
private static instance: PanelService;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): PanelService {
|
||||
if (!PanelService.instance) {
|
||||
PanelService.instance = new PanelService();
|
||||
}
|
||||
return PanelService.instance;
|
||||
}
|
||||
|
||||
validateText(text: string): boolean {
|
||||
return text.trim().length > 0;
|
||||
}
|
||||
|
||||
isDuplicateText(texts: Array<{ id: string; text: string }>, text: string, excludeId?: string): boolean {
|
||||
return texts.some((t) => t.id !== excludeId && t.text === text);
|
||||
}
|
||||
|
||||
addText(texts: Array<{ id: string; text: string }>, text: string): Array<{ id: string; text: string }> {
|
||||
if (!this.validateText(text)) return texts;
|
||||
if (this.isDuplicateText(texts, text)) return texts;
|
||||
|
||||
return [...texts, { id: crypto.randomUUID(), text }];
|
||||
}
|
||||
|
||||
updateText(
|
||||
texts: Array<{ id: string; text: string }>,
|
||||
id: string,
|
||||
newText: string,
|
||||
): Array<{ id: string; text: string }> {
|
||||
if (!this.validateText(newText)) return texts;
|
||||
if (this.isDuplicateText(texts, newText, id)) return texts;
|
||||
|
||||
return texts.map((t) => (t.id === id ? { ...t, text: newText } : t));
|
||||
}
|
||||
|
||||
deleteText(texts: Array<{ id: string; text: string }>, id: string): Array<{ id: string; text: string }> {
|
||||
return texts.filter((t) => t.id !== id);
|
||||
}
|
||||
|
||||
updatePanelsFromTexts(
|
||||
texts: Array<{ id: string; text: string }>,
|
||||
panels: Panel[],
|
||||
backgroundImage: string,
|
||||
textSettings?: Partial<TextItem>,
|
||||
): Panel[] {
|
||||
return texts.map((textItem) => {
|
||||
const existingPanel = panels.find((p) => p.text.text === textItem.text);
|
||||
if (existingPanel) {
|
||||
return updatePanelText(existingPanel, textItem.text);
|
||||
}
|
||||
return createPanelFromText(backgroundImage, textItem.text, PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT, textSettings);
|
||||
});
|
||||
}
|
||||
|
||||
updatePanelsBackground(panels: Panel[], newBackground: string): Panel[] {
|
||||
return panels.map((panel) => ({
|
||||
...panel,
|
||||
backgroundImage: newBackground,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export const panelService = PanelService.getInstance();
|
||||
@@ -0,0 +1,51 @@
|
||||
export type UploadResult =
|
||||
| { ok: true; data: string; error?: never }
|
||||
| { ok: false; error: string; data?: never };
|
||||
|
||||
export const uploadService = {
|
||||
async _process(file: File): Promise<UploadResult> {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
return { ok: false, error: "Not an image" };
|
||||
}
|
||||
try {
|
||||
const base64 = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
resolve(reader.result as string);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
const img = new Image();
|
||||
img.src = base64;
|
||||
await img.decode();
|
||||
|
||||
return { ok: true, data: base64 };
|
||||
} catch {
|
||||
return { ok: false, error: "File damage or unsupported" };
|
||||
}
|
||||
},
|
||||
|
||||
async fromFile(file: File | undefined): Promise<UploadResult> {
|
||||
if (!file) return { ok: false, error: "No file" };
|
||||
return this._process(file);
|
||||
},
|
||||
|
||||
async fromClipboard(event: ClipboardEvent): Promise<UploadResult> {
|
||||
const file = event.clipboardData?.files[0];
|
||||
return this.fromFile(file);
|
||||
},
|
||||
|
||||
async fromUrl(url: string): Promise<UploadResult> {
|
||||
if (!url) return { ok: false, error: "No url" };
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return { ok: false, error: "Invalid url" };
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], "image", { type: blob.type });
|
||||
return this._process(file);
|
||||
} catch {
|
||||
return { ok: false, error: "Invalid url, or CORS error" };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import { IMAGE_SETTINGS } from "$lib/constants";
|
||||
import { withPersistence, type Persistable } from "./persisted.svelte";
|
||||
|
||||
// export const DEBOUNCE_DURATION = import.meta.env.MODE === "test" ? 0 : 200;
|
||||
export const DEBOUNCE_DURATION = 150;
|
||||
export const INITIAL_DELAY_DURATION = 500;
|
||||
|
||||
export interface Rect {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export interface ImageConfigDTO {
|
||||
fullImage: string | null;
|
||||
cropRect: Rect;
|
||||
brightness: number;
|
||||
contrast: number;
|
||||
hue: number;
|
||||
saturation: number;
|
||||
luminance: number;
|
||||
}
|
||||
|
||||
const ImageConfigDTOKeys: (keyof ImageConfigDTO)[] = [
|
||||
"fullImage",
|
||||
"cropRect",
|
||||
"brightness",
|
||||
"contrast",
|
||||
"hue",
|
||||
"saturation",
|
||||
"luminance",
|
||||
];
|
||||
|
||||
export interface ImageConfig {
|
||||
fullImage: string | null;
|
||||
croppedImage: HTMLImageElement | undefined;
|
||||
cropRect: Rect;
|
||||
brightness: number;
|
||||
contrast: number;
|
||||
hue: number;
|
||||
saturation: number;
|
||||
luminance: number;
|
||||
isReady: boolean;
|
||||
appliedFilters: FilterConfig;
|
||||
}
|
||||
|
||||
interface FilterConfig {
|
||||
brightness: number;
|
||||
contrast: number;
|
||||
hue: number;
|
||||
saturation: number;
|
||||
luminance: number;
|
||||
}
|
||||
|
||||
export class ImageState implements Persistable<ImageConfigDTO> {
|
||||
#fullImage: string = $state("");
|
||||
croppedImage: HTMLImageElement | undefined = $state(undefined);
|
||||
masterImage: HTMLCanvasElement | undefined = $state(undefined);
|
||||
cropRect: Rect = $state({ left: 0, top: 0, right: 0, bottom: 0 });
|
||||
|
||||
brightness: number = $state(IMAGE_SETTINGS.BRIGHTNESS_DEFAULT);
|
||||
contrast: number = $state(IMAGE_SETTINGS.CONTRAST_DEFAULT);
|
||||
hue: number = $state(IMAGE_SETTINGS.HUE_DEFAULT);
|
||||
saturation: number = $state(IMAGE_SETTINGS.SATURATION_DEFAULT);
|
||||
luminance: number = $state(IMAGE_SETTINGS.LUMINANCE_DEFAULT);
|
||||
isReady: boolean = $state(false);
|
||||
|
||||
appliedFilters: FilterConfig = $state({
|
||||
brightness: 0,
|
||||
contrast: 0,
|
||||
hue: 0,
|
||||
saturation: 0,
|
||||
luminance: 0,
|
||||
});
|
||||
|
||||
constructor() {
|
||||
$effect.root(() => {
|
||||
$effect(() => {
|
||||
const brightness = this.brightness;
|
||||
const contrast = this.contrast;
|
||||
const hue = this.hue;
|
||||
const saturation = this.saturation;
|
||||
const luminance = this.luminance;
|
||||
this.isReady = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
this.updateFilters({
|
||||
hue,
|
||||
saturation,
|
||||
luminance,
|
||||
brightness,
|
||||
contrast,
|
||||
});
|
||||
}, DEBOUNCE_DURATION);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateFilters(newFilters: FilterConfig) {
|
||||
this.appliedFilters = newFilters;
|
||||
this.isReady = true;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.#fullImage = "";
|
||||
this.croppedImage = undefined;
|
||||
this.cropRect = { left: 0, top: 0, right: 0, bottom: 0 };
|
||||
this.brightness = 0;
|
||||
this.contrast = 0;
|
||||
this.hue = 0;
|
||||
}
|
||||
get fullImage() {
|
||||
return this.#fullImage;
|
||||
}
|
||||
set fullImage(image: string) {
|
||||
if (image) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
this.#fullImage = image;
|
||||
this.croppedImage = img;
|
||||
};
|
||||
img.src = image;
|
||||
}
|
||||
}
|
||||
toSnapshot(): ImageConfigDTO {
|
||||
return {
|
||||
fullImage: this.#fullImage,
|
||||
cropRect: this.cropRect,
|
||||
brightness: this.brightness,
|
||||
contrast: this.contrast,
|
||||
hue: this.hue,
|
||||
saturation: this.saturation,
|
||||
luminance: this.luminance,
|
||||
};
|
||||
}
|
||||
fromSnapshot(data: Partial<ImageConfigDTO>): void {
|
||||
for (const key of ImageConfigDTOKeys) {
|
||||
if (key in data && data[key] !== undefined) {
|
||||
(this as ImageConfigDTO)[key] = data[key] as never;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const imageState = withPersistence("image", new ImageState());
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { Stage } from "svelte-konva";
|
||||
|
||||
export const konvaAllStagesState: Array<Stage> = $state([]);
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Stage } from "svelte-konva";
|
||||
|
||||
export class KonvaStage {
|
||||
stage: Stage | undefined = $state(undefined);
|
||||
}
|
||||
|
||||
export const konvaStageState = new KonvaStage();
|
||||
@@ -0,0 +1,45 @@
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
export const DEBOUNCE_DURATION = import.meta.env.MODE === "test" ? 0 : 500;
|
||||
|
||||
export interface Persistable<D> {
|
||||
toSnapshot(): D;
|
||||
fromSnapshot(data: Partial<D>): void;
|
||||
}
|
||||
|
||||
export function withPersistence<D, T extends Persistable<D>>(
|
||||
key: string,
|
||||
state: T,
|
||||
debounceMs = DEBOUNCE_DURATION,
|
||||
): T {
|
||||
if (!browser) return state;
|
||||
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
|
||||
if (parsed) {
|
||||
state.fromSnapshot(parsed);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error repairing state for ${key}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
$effect.root(() => {
|
||||
$effect(() => {
|
||||
const data = JSON.stringify($state.snapshot(state.toSnapshot()));
|
||||
|
||||
if (debounceMs > 0) {
|
||||
const timeout = setTimeout(() => localStorage.setItem(key, data), debounceMs);
|
||||
return () => clearTimeout(timeout);
|
||||
} else {
|
||||
localStorage.setItem(key, data);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { TEXT_ALIGN_DEFAULT, TYPOGRAPHY, type TextAlignType } from "$lib/constants";
|
||||
import type { HexColor } from "$lib/types";
|
||||
import { withPersistence, type Persistable } from "./persisted.svelte";
|
||||
|
||||
export type TextConfigDTO = {
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
color: HexColor;
|
||||
align: TextAlignType;
|
||||
paddingX: number;
|
||||
offsetY: number;
|
||||
outlined: boolean;
|
||||
};
|
||||
|
||||
const textConfigKeys: (keyof TextConfigDTO)[] = [
|
||||
"fontSize",
|
||||
"fontFamily",
|
||||
"color",
|
||||
"align",
|
||||
"paddingX",
|
||||
"offsetY",
|
||||
"outlined",
|
||||
];
|
||||
|
||||
export class TextConfigState implements Persistable<TextConfigDTO> {
|
||||
fontSize: number = $state(TYPOGRAPHY.FONT_SIZE_DEFAULT);
|
||||
fontFamily: string = $state(TYPOGRAPHY.FONT_FAMILY_DEFAULT);
|
||||
color: HexColor = $state(TYPOGRAPHY.TEXT_COLOR_DEFAULT);
|
||||
align: TextAlignType = $state(TEXT_ALIGN_DEFAULT);
|
||||
paddingX: number = $state(TYPOGRAPHY.PADDING_X_DEFAULT);
|
||||
offsetY: number = $state(TYPOGRAPHY.OFFSET_Y_DEFAULT);
|
||||
outlined: boolean = $state(false);
|
||||
|
||||
toSnapshot(): TextConfigDTO {
|
||||
return {
|
||||
fontSize: this.fontSize,
|
||||
fontFamily: this.fontFamily,
|
||||
color: this.color,
|
||||
align: this.align,
|
||||
paddingX: this.paddingX,
|
||||
offsetY: this.offsetY,
|
||||
outlined: this.outlined,
|
||||
};
|
||||
}
|
||||
|
||||
fromSnapshot(data: Partial<TextConfigDTO>): void {
|
||||
for (const key of textConfigKeys) {
|
||||
if (key in data && data[key] !== undefined) {
|
||||
(this as TextConfigDTO)[key] = data[key] as never;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const textConfigState = withPersistence("text-config", new TextConfigState());
|
||||
@@ -0,0 +1,50 @@
|
||||
import { withPersistence, type Persistable } from "./persisted.svelte";
|
||||
|
||||
export interface TextItem {
|
||||
text: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export type TextState = Array<TextItem>;
|
||||
export type TextStateDTO = Array<string>;
|
||||
|
||||
const defaultTexts: TextStateDTO = ["About me", "Links", "Projects"];
|
||||
|
||||
export class TextsState implements Persistable<TextStateDTO> {
|
||||
#texts: Array<TextItem> = $state(this.fromDTO(defaultTexts));
|
||||
#nextId = $state(defaultTexts.length);
|
||||
|
||||
get texts() {
|
||||
return this.#texts;
|
||||
}
|
||||
|
||||
addText(text: string) {
|
||||
if (text.trim().length === 0) return;
|
||||
this.#texts.push({ text, id: this.#nextId });
|
||||
this.#nextId++;
|
||||
}
|
||||
|
||||
removeText(id: number) {
|
||||
this.#texts = this.#texts.filter((textItem) => textItem.id !== id);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.#texts = [];
|
||||
this.#nextId = 0;
|
||||
}
|
||||
|
||||
toSnapshot(): TextStateDTO {
|
||||
return this.#texts.map(({ text }) => text);
|
||||
}
|
||||
|
||||
fromSnapshot(data: TextStateDTO) {
|
||||
this.#texts = this.fromDTO(data);
|
||||
this.#nextId = data.length;
|
||||
}
|
||||
|
||||
fromDTO(data: TextStateDTO): TextState {
|
||||
return data.map((text, idx) => ({ text, id: idx }));
|
||||
}
|
||||
}
|
||||
|
||||
export const textsState = withPersistence("texts", new TextsState());
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Theme, type ThemeType } from "$lib/constants";
|
||||
import { withPersistence, type Persistable } from "./persisted.svelte";
|
||||
|
||||
export interface Theme {
|
||||
current: ThemeType;
|
||||
}
|
||||
|
||||
export class ThemeState implements Persistable<Theme> {
|
||||
current: ThemeType = $state(Theme.LIGHT);
|
||||
|
||||
toggle() {
|
||||
this.current = this.current === Theme.DARK ? Theme.LIGHT : Theme.DARK;
|
||||
}
|
||||
|
||||
toSnapshot(): Theme {
|
||||
return {
|
||||
current: this.current,
|
||||
};
|
||||
}
|
||||
|
||||
fromSnapshot(data: Partial<Theme>): void {
|
||||
if (data.current !== undefined) {
|
||||
this.current = data.current;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const themeState = withPersistence("theme", new ThemeState());
|
||||
@@ -1,88 +0,0 @@
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { PANEL_SETTINGS, TYPOGRAPHY } from "../lib/constants";
|
||||
import { type Panel, type TextItem } from "../lib/types/panel";
|
||||
|
||||
export const panelStore: Writable<Panel | undefined> = writable(undefined);
|
||||
|
||||
export const textSettingsStore = writable<Partial<TextItem>>({
|
||||
fontSize: TYPOGRAPHY.FONT_SIZE_DEFAULT,
|
||||
fontFamily: TYPOGRAPHY.FONT_FAMILY_DEFAULT,
|
||||
color: TYPOGRAPHY.TEXT_COLOR_DEFAULT,
|
||||
textAlign: TYPOGRAPHY.TEXT_ALIGN_LEFT,
|
||||
paddingX: TYPOGRAPHY.PADDING_X_DEFAULT,
|
||||
verticalOffset: 0,
|
||||
});
|
||||
|
||||
export const updateAllTextSettings = (settings: Partial<TextItem>) => {
|
||||
textSettingsStore.update((current) => ({ ...current, ...settings }));
|
||||
};
|
||||
|
||||
export const createEmptyPanel = (height: number = PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT): Panel => {
|
||||
const defaultText: TextItem = {
|
||||
id: uuidv4(),
|
||||
text: "",
|
||||
fontSize: TYPOGRAPHY.FONT_SIZE_DEFAULT,
|
||||
fontFamily: TYPOGRAPHY.FONT_FAMILY_DEFAULT,
|
||||
color: TYPOGRAPHY.TEXT_COLOR_DEFAULT,
|
||||
textAlign: TYPOGRAPHY.TEXT_ALIGN_CENTER,
|
||||
paddingX: TYPOGRAPHY.PADDING_X_DEFAULT,
|
||||
verticalOffset: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
id: uuidv4(),
|
||||
backgroundImage: "",
|
||||
text: defaultText,
|
||||
height,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
};
|
||||
|
||||
export const updatePanel = (panel: Panel, updates: Partial<Panel>): Panel => {
|
||||
return {
|
||||
...panel,
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
};
|
||||
|
||||
export const updatePanelText = (panel: Panel, text: string): Panel => {
|
||||
return updatePanel(panel, {
|
||||
text: { ...panel.text, text },
|
||||
});
|
||||
};
|
||||
|
||||
export const updateTextProperties = (panel: Panel, updates: Partial<TextItem>): Panel => {
|
||||
return updatePanel(panel, {
|
||||
text: { ...panel.text, ...updates },
|
||||
});
|
||||
};
|
||||
|
||||
export const createPanelFromText = (
|
||||
backgroundImage: string,
|
||||
text: string,
|
||||
height: number = PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT,
|
||||
textSettings?: Partial<TextItem>,
|
||||
): Panel => {
|
||||
const newText: TextItem = {
|
||||
id: uuidv4(),
|
||||
text,
|
||||
fontSize: textSettings?.fontSize ?? TYPOGRAPHY.FONT_SIZE_DEFAULT,
|
||||
fontFamily: textSettings?.fontFamily ?? TYPOGRAPHY.FONT_FAMILY_DEFAULT,
|
||||
color: textSettings?.color ?? TYPOGRAPHY.TEXT_COLOR_DEFAULT,
|
||||
textAlign: textSettings?.textAlign ?? TYPOGRAPHY.TEXT_ALIGN_CENTER,
|
||||
paddingX: textSettings?.paddingX ?? TYPOGRAPHY.PADDING_X_DEFAULT,
|
||||
verticalOffset: textSettings?.verticalOffset ?? 0,
|
||||
};
|
||||
|
||||
return {
|
||||
id: uuidv4(),
|
||||
backgroundImage,
|
||||
text: newText,
|
||||
height,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
import { type UIState } from "../lib/types/panel";
|
||||
|
||||
export const uiStore: Writable<UIState> = writable({
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
currentStep: "text",
|
||||
showCropModal: false,
|
||||
showTextManager: false,
|
||||
});
|
||||
|
||||
export const setLoading = (loading: boolean): void => {
|
||||
uiStore.update((state) => ({ ...state, isLoading: loading }));
|
||||
};
|
||||
|
||||
export const setError = (error: string | undefined): void => {
|
||||
uiStore.update((state) => ({ ...state, error }));
|
||||
};
|
||||
|
||||
export const clearError = (): void => {
|
||||
setError(undefined);
|
||||
};
|
||||
|
||||
export const setCurrentStep = (step: UIState["currentStep"]): void => {
|
||||
uiStore.update((state) => ({ ...state, currentStep: step }));
|
||||
};
|
||||
|
||||
export const showCropModal = (show: boolean): void => {
|
||||
uiStore.update((state) => ({ ...state, showCropModal: show }));
|
||||
};
|
||||
|
||||
export const showTextManager = (show: boolean): void => {
|
||||
uiStore.update((state) => ({ ...state, showTextManager: show }));
|
||||
};
|
||||
|
||||
export const resetUI = (): void => {
|
||||
uiStore.set({
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
currentStep: "text",
|
||||
showCropModal: false,
|
||||
showTextManager: false,
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user