style: add eslint and prettier, fix style errors
This commit is contained in:
+79
-79
@@ -1,79 +1,79 @@
|
||||
:root {
|
||||
--brand-main: #86efac;
|
||||
--brand-alt: oklch(from var(--brand-main) l c calc(h + 36));
|
||||
|
||||
--action-lightness: 0.6;
|
||||
--action-chroma: 0.18;
|
||||
--hover-step: -0.08;
|
||||
|
||||
--danger-base: #ef4444;
|
||||
|
||||
--surface-main: oklch(from var(--brand-main) 0.99 0.02 h);
|
||||
--surface-subtle: oklch(from var(--brand-main) 0.95 0.1 h);
|
||||
--surface-elevated: oklch(from var(--brand-main) 0.99 0.05 h);
|
||||
--surface-control: oklch(from var(--brand-main) 0.85 0.04 h);
|
||||
--surface-hover: oklch(from var(--brand-main) 0.9 0.1 h);
|
||||
|
||||
--action-primary: oklch(from var(--brand-main) var(--action-lightness) var(--action-chroma) h);
|
||||
--action-primary-hover: oklch(from var(--action-primary) calc(l - var(--hover-step)) c h);
|
||||
--action-secondary: oklch(from var(--brand-alt) var(--action-lightness) var(--action-chroma) h);
|
||||
--action-secondary-hover: oklch(from var(--action-secondary) calc(l - var(--hover-step)) c h);
|
||||
|
||||
--text-main: oklch(from var(--brand-main) 0.25 0.02 h);
|
||||
--text-muted: oklch(from var(--brand-main) 0.45 0.02 h);
|
||||
--text-action: oklch(from var(--brand-main) 0.99 0.02 h);
|
||||
|
||||
--border-main: oklch(from var(--brand-main) 0.9 0.04 h);
|
||||
--border-strong: oklch(from var(--brand-main) 0.8 0.06 h);
|
||||
|
||||
--shadow: 0 1px 3px oklch(from var(--brand-main) 0.2 0.05 h / 0.1);
|
||||
--shadow-md: 0 4px 12px oklch(from var(--brand-main) 0.2 0.05 h / 0.1);
|
||||
|
||||
--radius: 8px;
|
||||
--transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--action-lightness: 0.5;
|
||||
--action-chroma: 0.18;
|
||||
|
||||
--surface-main: oklch(from var(--brand-main) 0.12 0.02 h);
|
||||
--surface-subtle: oklch(from var(--brand-main) 0.16 0.03 h);
|
||||
--surface-elevated: oklch(from var(--brand-main) 0.2 0.03 h);
|
||||
--surface-control: oklch(from var(--brand-main) 0.4 0.06 h);
|
||||
--surface-hover: oklch(from var(--brand-main) 0.24 0.04 h);
|
||||
|
||||
--action-primary-hover: oklch(from var(--action-primary) calc(l + var(--hover-step)) c h);
|
||||
--action-secondary-hover: oklch(from var(--action-secondary) calc(l + var(--hover-step)) c h);
|
||||
|
||||
--text-main: oklch(from var(--brand-main) 0.94 0.01 h);
|
||||
--text-muted: oklch(from var(--brand-main) 0.75 0.02 h);
|
||||
--text-action: oklch(from var(--brand-main) 0.12 0.02 h);
|
||||
|
||||
--border-main: oklch(from var(--brand-main) 0.22 0.04 h);
|
||||
--border-strong: oklch(from var(--brand-main) 0.3 0.06 h);
|
||||
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
text-indent: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif;
|
||||
background: var(--surface-main);
|
||||
color: var(--text-main);
|
||||
min-height: 100vh;
|
||||
padding: 16px;
|
||||
transition: var(--transition);
|
||||
line-height: 1.5;
|
||||
}
|
||||
:root {
|
||||
--brand-main: #86efac;
|
||||
--brand-alt: oklch(from var(--brand-main) l c calc(h + 36));
|
||||
|
||||
--action-lightness: 0.6;
|
||||
--action-chroma: 0.18;
|
||||
--hover-step: -0.08;
|
||||
|
||||
--danger-base: #ef4444;
|
||||
|
||||
--surface-main: oklch(from var(--brand-main) 0.99 0.02 h);
|
||||
--surface-subtle: oklch(from var(--brand-main) 0.95 0.1 h);
|
||||
--surface-elevated: oklch(from var(--brand-main) 0.99 0.05 h);
|
||||
--surface-control: oklch(from var(--brand-main) 0.85 0.04 h);
|
||||
--surface-hover: oklch(from var(--brand-main) 0.9 0.1 h);
|
||||
|
||||
--action-primary: oklch(from var(--brand-main) var(--action-lightness) var(--action-chroma) h);
|
||||
--action-primary-hover: oklch(from var(--action-primary) calc(l - var(--hover-step)) c h);
|
||||
--action-secondary: oklch(from var(--brand-alt) var(--action-lightness) var(--action-chroma) h);
|
||||
--action-secondary-hover: oklch(from var(--action-secondary) calc(l - var(--hover-step)) c h);
|
||||
|
||||
--text-main: oklch(from var(--brand-main) 0.25 0.02 h);
|
||||
--text-muted: oklch(from var(--brand-main) 0.45 0.02 h);
|
||||
--text-action: oklch(from var(--brand-main) 0.99 0.02 h);
|
||||
|
||||
--border-main: oklch(from var(--brand-main) 0.9 0.04 h);
|
||||
--border-strong: oklch(from var(--brand-main) 0.8 0.06 h);
|
||||
|
||||
--shadow: 0 1px 3px oklch(from var(--brand-main) 0.2 0.05 h / 0.1);
|
||||
--shadow-md: 0 4px 12px oklch(from var(--brand-main) 0.2 0.05 h / 0.1);
|
||||
|
||||
--radius: 8px;
|
||||
--transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--action-lightness: 0.5;
|
||||
--action-chroma: 0.18;
|
||||
|
||||
--surface-main: oklch(from var(--brand-main) 0.12 0.02 h);
|
||||
--surface-subtle: oklch(from var(--brand-main) 0.16 0.03 h);
|
||||
--surface-elevated: oklch(from var(--brand-main) 0.2 0.03 h);
|
||||
--surface-control: oklch(from var(--brand-main) 0.4 0.06 h);
|
||||
--surface-hover: oklch(from var(--brand-main) 0.24 0.04 h);
|
||||
|
||||
--action-primary-hover: oklch(from var(--action-primary) calc(l + var(--hover-step)) c h);
|
||||
--action-secondary-hover: oklch(from var(--action-secondary) calc(l + var(--hover-step)) c h);
|
||||
|
||||
--text-main: oklch(from var(--brand-main) 0.94 0.01 h);
|
||||
--text-muted: oklch(from var(--brand-main) 0.75 0.02 h);
|
||||
/* --text-action: oklch(from var(--brand-main) 0.12 0.02 h); */
|
||||
|
||||
--border-main: oklch(from var(--brand-main) 0.22 0.04 h);
|
||||
--border-strong: oklch(from var(--brand-main) 0.3 0.06 h);
|
||||
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
text-indent: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif;
|
||||
background: var(--surface-main);
|
||||
color: var(--text-main);
|
||||
min-height: 100vh;
|
||||
padding: 16px;
|
||||
transition: var(--transition);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -1,108 +1,108 @@
|
||||
<script lang="ts">
|
||||
let active = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="crop-canvas-container">
|
||||
<canvas class="crop-canvas"></canvas>
|
||||
<div class="crop-box" class:active>
|
||||
<div class="crop-handle nw"></div>
|
||||
<div class="crop-handle ne"></div>
|
||||
<div class="crop-handle sw"></div>
|
||||
<div class="crop-handle se"></div>
|
||||
<div class="crop-handle n"></div>
|
||||
<div class="crop-handle s"></div>
|
||||
<div class="crop-handle w"></div>
|
||||
<div class="crop-handle e"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.crop-canvas-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 5;
|
||||
background: var(--surface-subtle);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.crop-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.crop-box {
|
||||
position: absolute;
|
||||
border: 2px solid var(--action-primary);
|
||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
|
||||
cursor: move;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.crop-box.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.crop-handle {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: white;
|
||||
border: 2px solid var(--action-primary);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.crop-handle.nw {
|
||||
top: -5px;
|
||||
left: -5px;
|
||||
cursor: nw-resize;
|
||||
}
|
||||
|
||||
.crop-handle.ne {
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
cursor: ne-resize;
|
||||
}
|
||||
|
||||
.crop-handle.sw {
|
||||
bottom: -5px;
|
||||
left: -5px;
|
||||
cursor: sw-resize;
|
||||
}
|
||||
|
||||
.crop-handle.se {
|
||||
bottom: -5px;
|
||||
right: -5px;
|
||||
cursor: se-resize;
|
||||
}
|
||||
|
||||
.crop-handle.n {
|
||||
top: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
cursor: n-resize;
|
||||
}
|
||||
|
||||
.crop-handle.s {
|
||||
bottom: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
cursor: s-resize;
|
||||
}
|
||||
|
||||
.crop-handle.w {
|
||||
left: -5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
cursor: w-resize;
|
||||
}
|
||||
|
||||
.crop-handle.e {
|
||||
right: -5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
cursor: e-resize;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
let active = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="crop-canvas-container">
|
||||
<canvas class="crop-canvas"></canvas>
|
||||
<div class="crop-box" class:active>
|
||||
<div class="crop-handle nw"></div>
|
||||
<div class="crop-handle ne"></div>
|
||||
<div class="crop-handle sw"></div>
|
||||
<div class="crop-handle se"></div>
|
||||
<div class="crop-handle n"></div>
|
||||
<div class="crop-handle s"></div>
|
||||
<div class="crop-handle w"></div>
|
||||
<div class="crop-handle e"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.crop-canvas-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 5;
|
||||
background: var(--surface-subtle);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.crop-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.crop-box {
|
||||
position: absolute;
|
||||
border: 2px solid var(--action-primary);
|
||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
|
||||
cursor: move;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.crop-box.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.crop-handle {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: white;
|
||||
border: 2px solid var(--action-primary);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.crop-handle.nw {
|
||||
top: -5px;
|
||||
left: -5px;
|
||||
cursor: nw-resize;
|
||||
}
|
||||
|
||||
.crop-handle.ne {
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
cursor: ne-resize;
|
||||
}
|
||||
|
||||
.crop-handle.sw {
|
||||
bottom: -5px;
|
||||
left: -5px;
|
||||
cursor: sw-resize;
|
||||
}
|
||||
|
||||
.crop-handle.se {
|
||||
bottom: -5px;
|
||||
right: -5px;
|
||||
cursor: se-resize;
|
||||
}
|
||||
|
||||
.crop-handle.n {
|
||||
top: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
cursor: n-resize;
|
||||
}
|
||||
|
||||
.crop-handle.s {
|
||||
bottom: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
cursor: s-resize;
|
||||
}
|
||||
|
||||
.crop-handle.w {
|
||||
left: -5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
cursor: w-resize;
|
||||
}
|
||||
|
||||
.crop-handle.e {
|
||||
right: -5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
cursor: e-resize;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,52 +1,52 @@
|
||||
<script lang="ts">
|
||||
import Card from "$components/layout/Card.svelte";
|
||||
import SettingsGrid from "$components/layout/SettingsGrid.svelte";
|
||||
import SettingsRow from "$components/layout/SettingsRow.svelte";
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import RangeSlider from "$components/ui/RangeSlider.svelte";
|
||||
// import { Pencil, RotateCcw as Reset, Upload } from "@lucide/svelte";
|
||||
// import Pencil from "@lucide/svelte/icons/pencil";
|
||||
// import Reset from "@lucide/svelte/icons/rotate-ccw";
|
||||
// import Upload from "@lucide/svelte/icons/upload";
|
||||
import Pencil from "~icons/lucide/pencil";
|
||||
import Reset from "~icons/lucide/rotate-ccw";
|
||||
import Upload from "~icons/lucide/upload";
|
||||
import CropInline from "./CropInline.svelte";
|
||||
|
||||
let brightness = $state(100);
|
||||
let contrast = $state(100);
|
||||
</script>
|
||||
|
||||
<Card title="Фоновое изображение">
|
||||
<div class="crop-editor">
|
||||
<CropInline />
|
||||
|
||||
<div class="crop-controls">
|
||||
<Button label="Загрузить" ariaLabel="Upload" icon={Upload} type="secondary" />
|
||||
<Button label="Редактировать" ariaLabel="Edit" icon={Reset} type="outline" />
|
||||
<Button label="Сбросить" ariaLabel="Reset" icon={Pencil} type="outline" />
|
||||
</div>
|
||||
|
||||
<SettingsGrid>
|
||||
<SettingsRow label="Яркость">
|
||||
<RangeSlider bind:value={brightness} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Контраст">
|
||||
<RangeSlider bind:value={contrast} min={50} max={150} />
|
||||
</SettingsRow>
|
||||
</SettingsGrid>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
.crop-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.crop-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import Card from "$components/layout/Card.svelte";
|
||||
import SettingsGrid from "$components/layout/SettingsGrid.svelte";
|
||||
import SettingsRow from "$components/layout/SettingsRow.svelte";
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import RangeSlider from "$components/ui/RangeSlider.svelte";
|
||||
// import { Pencil, RotateCcw as Reset, Upload } from "@lucide/svelte";
|
||||
// import Pencil from "@lucide/svelte/icons/pencil";
|
||||
// import Reset from "@lucide/svelte/icons/rotate-ccw";
|
||||
// import Upload from "@lucide/svelte/icons/upload";
|
||||
import Pencil from "~icons/lucide/pencil";
|
||||
import Reset from "~icons/lucide/rotate-ccw";
|
||||
import Upload from "~icons/lucide/upload";
|
||||
import CropInline from "./CropInline.svelte";
|
||||
|
||||
let brightness = $state(100);
|
||||
let contrast = $state(100);
|
||||
</script>
|
||||
|
||||
<Card title="Фоновое изображение">
|
||||
<div class="crop-editor">
|
||||
<CropInline />
|
||||
|
||||
<div class="crop-controls">
|
||||
<Button label="Загрузить" ariaLabel="Upload" icon={Upload} type="secondary" />
|
||||
<Button label="Редактировать" ariaLabel="Edit" icon={Reset} type="outline" />
|
||||
<Button label="Сбросить" ariaLabel="Reset" icon={Pencil} type="outline" />
|
||||
</div>
|
||||
|
||||
<SettingsGrid>
|
||||
<SettingsRow label="Яркость">
|
||||
<RangeSlider bind:value={brightness} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Контраст">
|
||||
<RangeSlider bind:value={contrast} min={50} max={150} />
|
||||
</SettingsRow>
|
||||
</SettingsGrid>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
.crop-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.crop-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,86 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { themeState } from "$states/theme.svelte";
|
||||
import Moon from "~icons/lucide/moon";
|
||||
import Sun from "~icons/lucide/sun";
|
||||
|
||||
function toggleTheme() {
|
||||
themeState.toggle();
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<h1>Twitch Panels</h1>
|
||||
<button class="theme-toggle" aria-label="Toggle theme" onclick={toggleTheme}>
|
||||
<Sun class="sun-icon" />
|
||||
<Moon class="moon-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
background: linear-gradient(135deg, var(--action-primary) 0%, var(--action-secondary) 100%);
|
||||
color: var(--text-action);
|
||||
padding: 16px 20px;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 16px;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: var(--transition);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
:global(.theme-toggle svg) {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
position: absolute;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
:global(.sun-icon) {
|
||||
opacity: 1;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
:global(.moon-icon) {
|
||||
opacity: 0;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"] .sun-icon) {
|
||||
opacity: 0;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"] .moon-icon) {
|
||||
opacity: 1;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import { themeState } from "$states/theme.svelte";
|
||||
import Moon from "~icons/lucide/moon";
|
||||
import Sun from "~icons/lucide/sun";
|
||||
|
||||
function toggleTheme() {
|
||||
themeState.toggle();
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<h1>Twitch Panels</h1>
|
||||
<button class="theme-toggle" aria-label="Toggle theme" onclick={toggleTheme}>
|
||||
<Sun class="sun-icon" />
|
||||
<Moon class="moon-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
background: linear-gradient(135deg, var(--action-primary) 0%, var(--action-secondary) 100%);
|
||||
color: var(--text-action);
|
||||
padding: 16px 20px;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 16px;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: var(--transition);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
:global(.theme-toggle svg) {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
position: absolute;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
:global(.sun-icon) {
|
||||
opacity: 1;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
:global(.moon-icon) {
|
||||
opacity: 0;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"] .sun-icon) {
|
||||
opacity: 0;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"] .moon-icon) {
|
||||
opacity: 1;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,68 +1,67 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
title: string | Snippet;
|
||||
children: Snippet;
|
||||
titleSnippet?: Snippet;
|
||||
}
|
||||
|
||||
let { title, children, titleSnippet = emptySnippet }: Props = $props();
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore block_empty -->
|
||||
{#snippet emptySnippet()}{/snippet}
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header-row">
|
||||
<h2 class="card-title">
|
||||
{#if typeof title === "string"}
|
||||
{title}
|
||||
{:else}
|
||||
{@render title()}
|
||||
{/if}
|
||||
</h2>
|
||||
<div class="card-snippet">
|
||||
{@render titleSnippet()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{@render children()}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
background: var(--surface-elevated);
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
transition: var(--transition);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-snippet {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
title: string | Snippet;
|
||||
children: Snippet;
|
||||
titleSnippet?: Snippet;
|
||||
}
|
||||
|
||||
let { title, children, titleSnippet = emptySnippet }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#snippet emptySnippet()}{/snippet}
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header-row">
|
||||
<h2 class="card-title">
|
||||
{#if typeof title === "string"}
|
||||
{title}
|
||||
{:else}
|
||||
{@render title()}
|
||||
{/if}
|
||||
</h2>
|
||||
<div class="card-snippet">
|
||||
{@render titleSnippet()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{@render children()}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
background: var(--surface-elevated);
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
transition: var(--transition);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-snippet {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="input-group">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="input-group">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import ImageManager from "$components/image/ImageManager.svelte";
|
||||
import PreviewManager from "$components/panel/PreviewManager.svelte";
|
||||
</script>
|
||||
|
||||
<div class="panel-bar">
|
||||
<ImageManager />
|
||||
<PreviewManager />
|
||||
</div>
|
||||
<script lang="ts">
|
||||
import ImageManager from "$components/image/ImageManager.svelte";
|
||||
import PreviewManager from "$components/panel/PreviewManager.svelte";
|
||||
</script>
|
||||
|
||||
<div class="panel-bar">
|
||||
<ImageManager />
|
||||
<PreviewManager />
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="settings-grid">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="settings-grid">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
children: Snippet;
|
||||
noLabel?: boolean;
|
||||
}
|
||||
|
||||
let { label, children, noLabel = false }: Props = $props();
|
||||
|
||||
let tag = $derived(noLabel ? "div" : "label");
|
||||
</script>
|
||||
|
||||
<svelte:element this={tag} class="setting-row">
|
||||
<div class="setting-label">{label}</div>
|
||||
<div class="setting-control">
|
||||
{@render children()}
|
||||
</div>
|
||||
</svelte:element>
|
||||
|
||||
<style>
|
||||
.setting-row {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.setting-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
children: Snippet;
|
||||
noLabel?: boolean;
|
||||
}
|
||||
|
||||
let { label, children, noLabel = false }: Props = $props();
|
||||
|
||||
let tag = $derived(noLabel ? "div" : "label");
|
||||
</script>
|
||||
|
||||
<svelte:element this={tag} class="setting-row">
|
||||
<div class="setting-label">{label}</div>
|
||||
<div class="setting-control">
|
||||
{@render children()}
|
||||
</div>
|
||||
</svelte:element>
|
||||
|
||||
<style>
|
||||
.setting-row {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.setting-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import TextConfig from "$components/text/TextConfig.svelte";
|
||||
import TextManager from "$components/text/TextManager.svelte";
|
||||
</script>
|
||||
|
||||
<div class="text-bar">
|
||||
<TextManager />
|
||||
<TextConfig />
|
||||
</div>
|
||||
<script lang="ts">
|
||||
import TextConfig from "$components/text/TextConfig.svelte";
|
||||
import TextManager from "$components/text/TextManager.svelte";
|
||||
</script>
|
||||
|
||||
<div class="text-bar">
|
||||
<TextManager />
|
||||
<TextConfig />
|
||||
</div>
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { PANEL_SETTINGS } from "$lib/constants";
|
||||
import { imageConfigState } from "$states/imageConfig.svelte";
|
||||
import { textConfigState } from "$states/textConfig.svelte";
|
||||
import { Image, Layer, Stage, Text } from "svelte-konva";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
stage: Stage | undefined;
|
||||
}
|
||||
|
||||
let { text, stage = $bindable() }: Props = $props();
|
||||
|
||||
let x = $derived(textConfigState.paddingX);
|
||||
let y = $derived(10 + textConfigState.offsetY);
|
||||
let width = $derived(PANEL_SETTINGS.PANEL_WIDTH - 2 * textConfigState.paddingX);
|
||||
let height = PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT;
|
||||
</script>
|
||||
|
||||
<Stage width={320} height={100} bind:this={stage}>
|
||||
<Layer>
|
||||
<Image image={imageConfigState.image}></Image>
|
||||
<Text
|
||||
{text}
|
||||
{x}
|
||||
{y}
|
||||
{width}
|
||||
{height}
|
||||
fontSize={textConfigState.fontSize}
|
||||
fill={textConfigState.color}
|
||||
fontFamily={textConfigState.fontFamily}
|
||||
align={textConfigState.align}
|
||||
wrap="none"
|
||||
ellipsis={true}
|
||||
/>
|
||||
</Layer>
|
||||
</Stage>
|
||||
<script lang="ts">
|
||||
import { PANEL_SETTINGS } from "$lib/constants";
|
||||
import { imageConfigState } from "$states/imageConfig.svelte";
|
||||
import { textConfigState } from "$states/textConfig.svelte";
|
||||
import { Image, Layer, Stage, Text } from "svelte-konva";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
stage: Stage | undefined;
|
||||
}
|
||||
|
||||
let { text, stage = $bindable() }: Props = $props();
|
||||
|
||||
let x = $derived(textConfigState.paddingX);
|
||||
let y = $derived(10 + textConfigState.offsetY);
|
||||
let width = $derived(PANEL_SETTINGS.PANEL_WIDTH - 2 * textConfigState.paddingX);
|
||||
let height = PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT;
|
||||
</script>
|
||||
|
||||
<Stage width={320} height={100} bind:this={stage}>
|
||||
<Layer>
|
||||
<Image image={imageConfigState.image}></Image>
|
||||
<Text
|
||||
{text}
|
||||
{x}
|
||||
{y}
|
||||
{width}
|
||||
{height}
|
||||
fontSize={textConfigState.fontSize}
|
||||
fill={textConfigState.color}
|
||||
fontFamily={textConfigState.fontFamily}
|
||||
align={textConfigState.align}
|
||||
wrap="none"
|
||||
ellipsis={true}
|
||||
/>
|
||||
</Layer>
|
||||
</Stage>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { konvaAllStagesState } from "$states/konvaAllStages.svelte";
|
||||
import { textsState } from "$states/texts.svelte";
|
||||
|
||||
import Preview from "./Preview.svelte";
|
||||
</script>
|
||||
|
||||
<div class="outside-display">
|
||||
{#each textsState.texts as { text, id }, idx (id)}
|
||||
<Preview {text} bind:stage={konvaAllStagesState[idx]} />
|
||||
{:else}
|
||||
<p>No texts to preview</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.outside-display {
|
||||
position: fixed;
|
||||
top: -1000px;
|
||||
left: -1000px;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import { konvaAllStagesState } from "$states/konvaAllStages.svelte";
|
||||
import { textsState } from "$states/texts.svelte";
|
||||
|
||||
import Preview from "./Preview.svelte";
|
||||
</script>
|
||||
|
||||
<div class="outside-display">
|
||||
{#each textsState.texts as { text, id }, idx (id)}
|
||||
<Preview {text} bind:stage={konvaAllStagesState[idx]} />
|
||||
{:else}
|
||||
<p>No texts to preview</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.outside-display {
|
||||
position: fixed;
|
||||
top: -1000px;
|
||||
left: -1000px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,40 +1,46 @@
|
||||
<script lang="ts">
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import { SlideDirection, type SlideDirectionType } from "$lib/constants";
|
||||
import ChevronLeft from "~icons/lucide/chevron-left";
|
||||
import ChevronRight from "~icons/lucide/chevron-right";
|
||||
|
||||
interface Props {
|
||||
current: number;
|
||||
direction: SlideDirectionType;
|
||||
max: number;
|
||||
}
|
||||
|
||||
let { current = $bindable(), direction = $bindable(SlideDirection.NEXT), max }: Props = $props();
|
||||
|
||||
let isFirst = $derived(current === 0);
|
||||
let isLast = $derived(current === max - 1);
|
||||
|
||||
function prev() {
|
||||
if (current > 0) current--;
|
||||
direction = SlideDirection.PREV;
|
||||
}
|
||||
|
||||
async function next() {
|
||||
if (current < max - 1) current++;
|
||||
direction = SlideDirection.NEXT;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button icon={ChevronLeft} ariaLabel="Previous slide" type="mini" onclick={prev} disabled={isFirst} />
|
||||
<span class="panel-indicator">{current + 1} / {max}</span>
|
||||
<Button icon={ChevronRight} ariaLabel="Next slide" type="mini" onclick={next} disabled={isLast} />
|
||||
|
||||
<style>
|
||||
.panel-indicator {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import { SlideDirection, type SlideDirectionType } from "$lib/constants";
|
||||
import ChevronLeft from "~icons/lucide/chevron-left";
|
||||
import ChevronRight from "~icons/lucide/chevron-right";
|
||||
|
||||
interface Props {
|
||||
current: number;
|
||||
direction: SlideDirectionType;
|
||||
max: number;
|
||||
}
|
||||
|
||||
let { current = $bindable(), direction = $bindable(SlideDirection.NEXT), max }: Props = $props();
|
||||
|
||||
let isFirst = $derived(current === 0);
|
||||
let isLast = $derived(current === max - 1);
|
||||
|
||||
function prev() {
|
||||
if (current > 0) current--;
|
||||
direction = SlideDirection.PREV;
|
||||
}
|
||||
|
||||
async function next() {
|
||||
if (current < max - 1) current++;
|
||||
direction = SlideDirection.NEXT;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button
|
||||
icon={ChevronLeft}
|
||||
ariaLabel="Previous slide"
|
||||
type="mini"
|
||||
onclick={prev}
|
||||
disabled={isFirst}
|
||||
/>
|
||||
<span class="panel-indicator">{current + 1} / {max}</span>
|
||||
<Button icon={ChevronRight} ariaLabel="Next slide" type="mini" onclick={next} disabled={isLast} />
|
||||
|
||||
<style>
|
||||
.panel-indicator {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,136 +1,144 @@
|
||||
<script lang="ts">
|
||||
import Card from "$components/layout/Card.svelte";
|
||||
import Badge from "$components/ui/Badge.svelte";
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import { PANEL_SETTINGS, TRANSITION_DURATION, type SlideDirectionType } from "$lib/constants";
|
||||
import { downloadService, type DownloadItem } from "$services/downloadService";
|
||||
import { konvaAllStagesState } from "$states/konvaAllStages.svelte";
|
||||
import { konvaStageState } from "$states/konvaStage.svelte";
|
||||
import { textsState } from "$states/texts.svelte";
|
||||
import { fly } from "svelte/transition";
|
||||
import Download from "~icons/lucide/download";
|
||||
import StickyNote from "~icons/lucide/sticky-note";
|
||||
import Preview from "./Preview.svelte";
|
||||
import PreviewAll from "./PreviewAll.svelte";
|
||||
import PreviewControls from "./PreviewControls.svelte";
|
||||
|
||||
let current: number = $state(0);
|
||||
let direction: SlideDirectionType = $state("next");
|
||||
|
||||
let xDirection = $derived(direction == "next" ? PANEL_SETTINGS.PANEL_WIDTH : -PANEL_SETTINGS.PANEL_WIDTH);
|
||||
|
||||
$effect(() => {
|
||||
if (textsState.texts.length === 0) {
|
||||
current = 0;
|
||||
} else {
|
||||
if (current > textsState.texts.length - 1) {
|
||||
current = textsState.texts.length - 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function downloadAll() {
|
||||
let downloadItems: Array<DownloadItem> = textsState.texts.map((text, idx) => ({
|
||||
filename: text.text,
|
||||
stage: konvaAllStagesState[idx]?.node,
|
||||
}));
|
||||
|
||||
downloadService.downloadAll(downloadItems);
|
||||
}
|
||||
|
||||
function downloadCurrent() {
|
||||
let node = konvaStageState.stage?.node;
|
||||
if (node) {
|
||||
downloadService.downloadPanel(node, textsState.texts[current].text);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet panelTitle()}
|
||||
Панели <Badge text={textsState.texts.length} />
|
||||
{/snippet}
|
||||
|
||||
{#snippet panelControls()}
|
||||
<PreviewControls bind:current bind:direction max={textsState.texts.length} />
|
||||
{/snippet}
|
||||
|
||||
<Card title={panelTitle} titleSnippet={panelControls}>
|
||||
<div class="panel-viewer">
|
||||
<div class="panel-display">
|
||||
{#if textsState.texts.length}
|
||||
{#key current}
|
||||
{@const text = textsState?.texts[current]?.text}
|
||||
<div
|
||||
class="konva-wrapper"
|
||||
in:fly={{ x: xDirection, duration: TRANSITION_DURATION }}
|
||||
out:fly={{ x: -xDirection, duration: TRANSITION_DURATION }}
|
||||
>
|
||||
<Preview {text} bind:stage={konvaStageState.stage} />
|
||||
</div>
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="empty-state" aria-label="Empty texts info">
|
||||
<StickyNote />
|
||||
<p>Добавьте тексты для создания панелей</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="panel-actions">
|
||||
<Button label="Скачать всё" ariaLabel="Download all" icon={Download} onclick={downloadAll} />
|
||||
<Button label="Скачать" ariaLabel="Download current" type="secondary" icon={Download} onclick={downloadCurrent} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<PreviewAll />
|
||||
|
||||
<style>
|
||||
.panel-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-display {
|
||||
background: var(--surface-subtle);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 150px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.empty-state :global(svg) {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.konva-wrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import Card from "$components/layout/Card.svelte";
|
||||
import Badge from "$components/ui/Badge.svelte";
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import { PANEL_SETTINGS, TRANSITION_DURATION, type SlideDirectionType } from "$lib/constants";
|
||||
import { downloadService, type DownloadItem } from "$services/downloadService";
|
||||
import { konvaAllStagesState } from "$states/konvaAllStages.svelte";
|
||||
import { konvaStageState } from "$states/konvaStage.svelte";
|
||||
import { textsState } from "$states/texts.svelte";
|
||||
import { fly } from "svelte/transition";
|
||||
import Download from "~icons/lucide/download";
|
||||
import StickyNote from "~icons/lucide/sticky-note";
|
||||
import Preview from "./Preview.svelte";
|
||||
import PreviewAll from "./PreviewAll.svelte";
|
||||
import PreviewControls from "./PreviewControls.svelte";
|
||||
|
||||
let current: number = $state(0);
|
||||
let direction: SlideDirectionType = $state("next");
|
||||
|
||||
let xDirection = $derived(
|
||||
direction == "next" ? PANEL_SETTINGS.PANEL_WIDTH : -PANEL_SETTINGS.PANEL_WIDTH,
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (textsState.texts.length === 0) {
|
||||
current = 0;
|
||||
} else {
|
||||
if (current > textsState.texts.length - 1) {
|
||||
current = textsState.texts.length - 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function downloadAll() {
|
||||
let downloadItems: Array<DownloadItem> = textsState.texts.map((text, idx) => ({
|
||||
filename: text.text,
|
||||
stage: konvaAllStagesState[idx]?.node,
|
||||
}));
|
||||
|
||||
downloadService.downloadAll(downloadItems);
|
||||
}
|
||||
|
||||
function downloadCurrent() {
|
||||
let node = konvaStageState.stage?.node;
|
||||
if (node) {
|
||||
downloadService.downloadPanel(node, textsState.texts[current].text);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet panelTitle()}
|
||||
Панели <Badge text={textsState.texts.length} />
|
||||
{/snippet}
|
||||
|
||||
{#snippet panelControls()}
|
||||
<PreviewControls bind:current bind:direction max={textsState.texts.length} />
|
||||
{/snippet}
|
||||
|
||||
<Card title={panelTitle} titleSnippet={panelControls}>
|
||||
<div class="panel-viewer">
|
||||
<div class="panel-display">
|
||||
{#if textsState.texts.length}
|
||||
{#key current}
|
||||
{@const text = textsState?.texts[current]?.text}
|
||||
<div
|
||||
class="konva-wrapper"
|
||||
in:fly={{ x: xDirection, duration: TRANSITION_DURATION }}
|
||||
out:fly={{ x: -xDirection, duration: TRANSITION_DURATION }}
|
||||
>
|
||||
<Preview {text} bind:stage={konvaStageState.stage} />
|
||||
</div>
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="empty-state" aria-label="Empty texts info">
|
||||
<StickyNote />
|
||||
<p>Добавьте тексты для создания панелей</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="panel-actions">
|
||||
<Button label="Скачать всё" ariaLabel="Download all" icon={Download} onclick={downloadAll} />
|
||||
<Button
|
||||
label="Скачать"
|
||||
ariaLabel="Download current"
|
||||
type="secondary"
|
||||
icon={Download}
|
||||
onclick={downloadCurrent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<PreviewAll />
|
||||
|
||||
<style>
|
||||
.panel-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-display {
|
||||
background: var(--surface-subtle);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 150px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.empty-state :global(svg) {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.konva-wrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
<script lang="ts">
|
||||
import Card from "$components/layout/Card.svelte";
|
||||
import SettingsGrid from "$components/layout/SettingsGrid.svelte";
|
||||
import SettingsRow from "$components/layout/SettingsRow.svelte";
|
||||
import Alignment from "$components/ui/Alignment.svelte";
|
||||
import ColorPicker from "$components/ui/ColorPicker.svelte";
|
||||
import RangeSlider from "$components/ui/RangeSlider.svelte";
|
||||
import SelectFont from "$components/ui/SelectFont.svelte";
|
||||
import { textConfigState } from "$states/textConfig.svelte";
|
||||
</script>
|
||||
|
||||
<Card title="Настройки текста">
|
||||
<SettingsGrid>
|
||||
<SettingsRow label="Размер">
|
||||
<RangeSlider bind:value={textConfigState.fontSize} min={10} max={100} step={1} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Шрифт">
|
||||
<SelectFont bind:value={textConfigState.fontFamily} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Цвет">
|
||||
<ColorPicker bind:value={textConfigState.color} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Выравнивание" noLabel={true}>
|
||||
<Alignment bind:align={textConfigState.align} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Отступы">
|
||||
<RangeSlider bind:value={textConfigState.paddingX} min={0} max={100} step={1} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Смещение">
|
||||
<RangeSlider bind:value={textConfigState.offsetY} min={-100} max={100} step={1} />
|
||||
</SettingsRow>
|
||||
</SettingsGrid>
|
||||
</Card>
|
||||
<script lang="ts">
|
||||
import Card from "$components/layout/Card.svelte";
|
||||
import SettingsGrid from "$components/layout/SettingsGrid.svelte";
|
||||
import SettingsRow from "$components/layout/SettingsRow.svelte";
|
||||
import Alignment from "$components/ui/Alignment.svelte";
|
||||
import ColorPicker from "$components/ui/ColorPicker.svelte";
|
||||
import RangeSlider from "$components/ui/RangeSlider.svelte";
|
||||
import SelectFont from "$components/ui/SelectFont.svelte";
|
||||
import { textConfigState } from "$states/textConfig.svelte";
|
||||
</script>
|
||||
|
||||
<Card title="Настройки текста">
|
||||
<SettingsGrid>
|
||||
<SettingsRow label="Размер">
|
||||
<RangeSlider bind:value={textConfigState.fontSize} min={10} max={100} step={1} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Шрифт">
|
||||
<SelectFont bind:value={textConfigState.fontFamily} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Цвет">
|
||||
<ColorPicker bind:value={textConfigState.color} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Выравнивание" noLabel={true}>
|
||||
<Alignment bind:align={textConfigState.align} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Отступы">
|
||||
<RangeSlider bind:value={textConfigState.paddingX} min={0} max={100} step={1} />
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Смещение">
|
||||
<RangeSlider bind:value={textConfigState.offsetY} min={-100} max={100} step={1} />
|
||||
</SettingsRow>
|
||||
</SettingsGrid>
|
||||
</Card>
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
<script lang="ts">
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import { TRANSITION_DURATION } from "$lib/constants";
|
||||
import { fly } from "svelte/transition";
|
||||
import Cross from "~icons/lucide/x";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
id: number;
|
||||
ondelete: (id: number) => void;
|
||||
}
|
||||
|
||||
let { text = $bindable(), id, ondelete }: Props = $props();
|
||||
</script>
|
||||
|
||||
<li class="text-item" transition:fly={{ x: 600, duration: TRANSITION_DURATION }}>
|
||||
<input type="text" bind:value={text} />
|
||||
<Button icon={Cross} ariaLabel="Delete" type="danger" onclick={() => ondelete(id)} />
|
||||
</li>
|
||||
|
||||
<style>
|
||||
.text-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.text-item:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.text-item input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background: var(--surface-main);
|
||||
color: var(--text-main);
|
||||
transition: var(--transition);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.text-item input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import { TRANSITION_DURATION } from "$lib/constants";
|
||||
import { fly } from "svelte/transition";
|
||||
import Cross from "~icons/lucide/x";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
id: number;
|
||||
ondelete: (id: number) => void;
|
||||
}
|
||||
|
||||
let { text = $bindable(), id, ondelete }: Props = $props();
|
||||
</script>
|
||||
|
||||
<li class="text-item" transition:fly={{ x: 600, duration: TRANSITION_DURATION }}>
|
||||
<input type="text" bind:value={text} />
|
||||
<Button icon={Cross} ariaLabel="Delete" type="danger" onclick={() => ondelete(id)} />
|
||||
</li>
|
||||
|
||||
<style>
|
||||
.text-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.text-item:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.text-item input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background: var(--surface-main);
|
||||
color: var(--text-main);
|
||||
transition: var(--transition);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.text-item input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
text: string;
|
||||
ariaLabel: string;
|
||||
onenter: () => void;
|
||||
}
|
||||
|
||||
let { text = $bindable(), onenter, ariaLabel }: Props = $props();
|
||||
|
||||
function handleKeyboard(event: KeyboardEvent) {
|
||||
if (event.key === "Enter") {
|
||||
onenter();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<input
|
||||
aria-label={ariaLabel}
|
||||
bind:value={text}
|
||||
class="text-input"
|
||||
type="text"
|
||||
placeholder="Введите текст..."
|
||||
onkeydown={handleKeyboard}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.text-input {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
font-size: 14px;
|
||||
background: var(--surface-main);
|
||||
color: var(--text-main);
|
||||
transition: var(--transition);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.text-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
text: string;
|
||||
ariaLabel: string;
|
||||
onenter: () => void;
|
||||
}
|
||||
|
||||
let { text = $bindable(), onenter, ariaLabel }: Props = $props();
|
||||
|
||||
function handleKeyboard(event: KeyboardEvent) {
|
||||
if (event.key === "Enter") {
|
||||
onenter();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<input
|
||||
aria-label={ariaLabel}
|
||||
bind:value={text}
|
||||
class="text-input"
|
||||
type="text"
|
||||
placeholder="Введите текст..."
|
||||
onkeydown={handleKeyboard}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.text-input {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
font-size: 14px;
|
||||
background: var(--surface-main);
|
||||
color: var(--text-main);
|
||||
transition: var(--transition);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.text-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
<script lang="ts">
|
||||
import Card from "$components/layout/Card.svelte";
|
||||
import InputGroup from "$components/layout/InputGroup.svelte";
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import { textsState } from "$states/texts.svelte";
|
||||
import Plus from "~icons/lucide/plus";
|
||||
import TextInlineEdit from "./TextInlineEdit.svelte";
|
||||
import TextInput from "./TextInput.svelte";
|
||||
|
||||
let text: string = $state("");
|
||||
|
||||
function addText() {
|
||||
textsState.addText(text);
|
||||
text = "";
|
||||
}
|
||||
|
||||
function deleteText(id: number) {
|
||||
textsState.removeText(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card title="Тексты панелей">
|
||||
<InputGroup>
|
||||
<TextInput ariaLabel="Input new text" bind:text onenter={addText} />
|
||||
<Button icon={Plus} ariaLabel="Add text" onclick={addText} />
|
||||
</InputGroup>
|
||||
<ul class="texts-list">
|
||||
{#each textsState.texts as { text, id } (id)}
|
||||
<TextInlineEdit {id} {text} ondelete={() => deleteText(id)} />
|
||||
{/each}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
.texts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import Card from "$components/layout/Card.svelte";
|
||||
import InputGroup from "$components/layout/InputGroup.svelte";
|
||||
import Button from "$components/ui/Button.svelte";
|
||||
import { textsState } from "$states/texts.svelte";
|
||||
import Plus from "~icons/lucide/plus";
|
||||
import TextInlineEdit from "./TextInlineEdit.svelte";
|
||||
import TextInput from "./TextInput.svelte";
|
||||
|
||||
let text: string = $state("");
|
||||
|
||||
function addText() {
|
||||
textsState.addText(text);
|
||||
text = "";
|
||||
}
|
||||
|
||||
function deleteText(id: number) {
|
||||
textsState.removeText(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card title="Тексты панелей">
|
||||
<InputGroup>
|
||||
<TextInput ariaLabel="Input new text" bind:text onenter={addText} />
|
||||
<Button icon={Plus} ariaLabel="Add text" onclick={addText} />
|
||||
</InputGroup>
|
||||
<ul class="texts-list">
|
||||
{#each textsState.texts as { text, id } (id)}
|
||||
<TextInlineEdit {id} {text} ondelete={() => deleteText(id)} />
|
||||
{/each}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
.texts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,99 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { TextAlign, type TextAlignType } from "$lib/constants";
|
||||
import TextAlignCenter from "~icons/lucide/text-align-center";
|
||||
import TextAlignEnd from "~icons/lucide/text-align-end";
|
||||
import TextAlignStart from "~icons/lucide/text-align-start";
|
||||
|
||||
interface Props {
|
||||
align: TextAlignType;
|
||||
}
|
||||
|
||||
let { align = $bindable(TextAlign.LEFT) }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="alignment-group">
|
||||
<label class="alignment-item">
|
||||
<input
|
||||
type="radio"
|
||||
name="alignment"
|
||||
value={TextAlign.LEFT}
|
||||
class="sr-only"
|
||||
bind:group={align}
|
||||
aria-label={`Set ${TextAlign.LEFT}`}
|
||||
/>
|
||||
<div class="alignment-button"><TextAlignStart /></div>
|
||||
</label>
|
||||
|
||||
<label class="alignment-item">
|
||||
<input
|
||||
type="radio"
|
||||
name="alignment"
|
||||
value={TextAlign.CENTER}
|
||||
class="sr-only"
|
||||
bind:group={align}
|
||||
aria-label={`Set ${TextAlign.CENTER}`}
|
||||
/>
|
||||
<div class="alignment-button"><TextAlignCenter /></div>
|
||||
</label>
|
||||
|
||||
<label class="alignment-item">
|
||||
<input
|
||||
type="radio"
|
||||
name="alignment"
|
||||
value={TextAlign.RIGHT}
|
||||
class="sr-only"
|
||||
bind:group={align}
|
||||
aria-label={`Set ${TextAlign.RIGHT}`}
|
||||
/>
|
||||
<div class="alignment-button"><TextAlignEnd /></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.alignment-group {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.alignment-button {
|
||||
flex-grow: 1;
|
||||
border: 1px solid var(--border-strong);
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.alignment-button:hover {
|
||||
color: var(--text-main);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.alignment-item {
|
||||
flex-grow: 1;
|
||||
& input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
&:first-child .alignment-button {
|
||||
border-top-left-radius: var(--radius);
|
||||
border-bottom-left-radius: var(--radius);
|
||||
}
|
||||
&:last-child .alignment-button {
|
||||
border-top-right-radius: var(--radius);
|
||||
border-bottom-right-radius: var(--radius);
|
||||
}
|
||||
}
|
||||
|
||||
.alignment-item input:checked + .alignment-button {
|
||||
background: var(--action-primary);
|
||||
color: var(--text-action);
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import { TextAlign, type TextAlignType } from "$lib/constants";
|
||||
import TextAlignCenter from "~icons/lucide/text-align-center";
|
||||
import TextAlignEnd from "~icons/lucide/text-align-end";
|
||||
import TextAlignStart from "~icons/lucide/text-align-start";
|
||||
|
||||
interface Props {
|
||||
align: TextAlignType;
|
||||
}
|
||||
|
||||
let { align = $bindable(TextAlign.LEFT) }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="alignment-group">
|
||||
<label class="alignment-item">
|
||||
<input
|
||||
type="radio"
|
||||
name="alignment"
|
||||
value={TextAlign.LEFT}
|
||||
class="sr-only"
|
||||
bind:group={align}
|
||||
aria-label={`Set ${TextAlign.LEFT}`}
|
||||
/>
|
||||
<div class="alignment-button"><TextAlignStart /></div>
|
||||
</label>
|
||||
|
||||
<label class="alignment-item">
|
||||
<input
|
||||
type="radio"
|
||||
name="alignment"
|
||||
value={TextAlign.CENTER}
|
||||
class="sr-only"
|
||||
bind:group={align}
|
||||
aria-label={`Set ${TextAlign.CENTER}`}
|
||||
/>
|
||||
<div class="alignment-button"><TextAlignCenter /></div>
|
||||
</label>
|
||||
|
||||
<label class="alignment-item">
|
||||
<input
|
||||
type="radio"
|
||||
name="alignment"
|
||||
value={TextAlign.RIGHT}
|
||||
class="sr-only"
|
||||
bind:group={align}
|
||||
aria-label={`Set ${TextAlign.RIGHT}`}
|
||||
/>
|
||||
<div class="alignment-button"><TextAlignEnd /></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.alignment-group {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.alignment-button {
|
||||
flex-grow: 1;
|
||||
border: 1px solid var(--border-strong);
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.alignment-button:hover {
|
||||
color: var(--text-main);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.alignment-item {
|
||||
flex-grow: 1;
|
||||
& input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
&:first-child .alignment-button {
|
||||
border-top-left-radius: var(--radius);
|
||||
border-bottom-left-radius: var(--radius);
|
||||
}
|
||||
&:last-child .alignment-button {
|
||||
border-top-right-radius: var(--radius);
|
||||
border-bottom-right-radius: var(--radius);
|
||||
}
|
||||
}
|
||||
|
||||
.alignment-item input:checked + .alignment-button {
|
||||
background: var(--action-primary);
|
||||
color: var(--text-action);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
text: string | number;
|
||||
}
|
||||
|
||||
let { text }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span class="badge">{text}</span>
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
background: var(--action-primary);
|
||||
color: var(--text-action);
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
text: string | number;
|
||||
}
|
||||
|
||||
let { text }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span class="badge">{text}</span>
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
background: var(--action-primary);
|
||||
color: var(--text-action);
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
+115
-115
@@ -1,115 +1,115 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from "svelte";
|
||||
import type { MouseEventHandler } from "svelte/elements";
|
||||
|
||||
interface Props {
|
||||
icon: Component;
|
||||
ariaLabel: string;
|
||||
onclick?: MouseEventHandler<HTMLButtonElement>;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
type?: "primary" | "secondary" | "danger" | "outline" | "mini";
|
||||
extra?: "grow";
|
||||
}
|
||||
|
||||
let {
|
||||
icon: Icon,
|
||||
ariaLabel,
|
||||
onclick = () => {},
|
||||
disabled = false,
|
||||
label = "",
|
||||
type = "primary",
|
||||
extra,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="btn"
|
||||
class:btn-primary={type === "primary"}
|
||||
class:btn-secondary={type === "secondary"}
|
||||
class:btn-outline={type === "outline"}
|
||||
class:btn-danger={type === "danger"}
|
||||
class:btn-mini={type === "mini"}
|
||||
class:grow={extra === "grow"}
|
||||
{disabled}
|
||||
{onclick}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<Icon />
|
||||
{label}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.btn {
|
||||
--btn-main: var(--action-primary);
|
||||
--btn-hover: var(--action-primary-hover);
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
background-color: var(--btn-main);
|
||||
color: oklch(from var(--btn-main) 99% 0.05 h);
|
||||
}
|
||||
|
||||
.btn :global(svg) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background-color: var(--btn-hover);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
--btn-main: var(--action-secondary);
|
||||
--btn-hover: var(--action-secondary-hover);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border-strong);
|
||||
}
|
||||
|
||||
.btn-outline:hover:not(:disabled) {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
--btn-main: var(--danger-base);
|
||||
}
|
||||
|
||||
.btn-mini {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-mini:hover:not(:disabled) {
|
||||
border-color: var(--border-main);
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import type { Component } from "svelte";
|
||||
import type { MouseEventHandler } from "svelte/elements";
|
||||
|
||||
interface Props {
|
||||
icon: Component;
|
||||
ariaLabel: string;
|
||||
onclick?: MouseEventHandler<HTMLButtonElement>;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
type?: "primary" | "secondary" | "danger" | "outline" | "mini";
|
||||
extra?: "grow";
|
||||
}
|
||||
|
||||
let {
|
||||
icon: Icon,
|
||||
ariaLabel,
|
||||
onclick = () => {},
|
||||
disabled = false,
|
||||
label = "",
|
||||
type = "primary",
|
||||
extra,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="btn"
|
||||
class:btn-primary={type === "primary"}
|
||||
class:btn-secondary={type === "secondary"}
|
||||
class:btn-outline={type === "outline"}
|
||||
class:btn-danger={type === "danger"}
|
||||
class:btn-mini={type === "mini"}
|
||||
class:grow={extra === "grow"}
|
||||
{disabled}
|
||||
{onclick}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<Icon />
|
||||
{label}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.btn {
|
||||
--btn-main: var(--action-primary);
|
||||
--btn-hover: var(--action-primary-hover);
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
background-color: var(--btn-main);
|
||||
color: var(--text-action);
|
||||
}
|
||||
|
||||
.btn :global(svg) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background-color: var(--btn-hover);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
--btn-main: var(--action-secondary);
|
||||
--btn-hover: var(--action-secondary-hover);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border-strong);
|
||||
}
|
||||
|
||||
.btn-outline:hover:not(:disabled) {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
--btn-main: var(--danger-base);
|
||||
}
|
||||
|
||||
.btn-mini {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-mini:hover:not(:disabled) {
|
||||
border-color: var(--border-main);
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
<script lang="ts">
|
||||
import type { HexColor } from "$lib/types";
|
||||
|
||||
interface Props {
|
||||
value: HexColor;
|
||||
}
|
||||
|
||||
let { value = $bindable() }: Props = $props();
|
||||
</script>
|
||||
|
||||
<input type="color" bind:value class="color-input" />
|
||||
<span class="color-value">{value}</span>
|
||||
|
||||
<style>
|
||||
.color-input {
|
||||
width: 40px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
background: var(--surface-main);
|
||||
padding: 0px 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.color-input:hover {
|
||||
border-color: var(--action-primary);
|
||||
}
|
||||
|
||||
.color-value {
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 8px;
|
||||
background: var(--surface-main);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import type { HexColor } from "$lib/types";
|
||||
|
||||
interface Props {
|
||||
value: HexColor;
|
||||
}
|
||||
|
||||
let { value = $bindable() }: Props = $props();
|
||||
</script>
|
||||
|
||||
<input type="color" bind:value class="color-input" />
|
||||
<span class="color-value">{value}</span>
|
||||
|
||||
<style>
|
||||
.color-input {
|
||||
width: 40px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
background: var(--surface-main);
|
||||
padding: 0px 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.color-input:hover {
|
||||
border-color: var(--action-primary);
|
||||
}
|
||||
|
||||
.color-value {
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 8px;
|
||||
background: var(--surface-main);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,68 +1,68 @@
|
||||
<script lang="ts">
|
||||
import type { ChangeEventHandler } from "svelte/elements";
|
||||
|
||||
interface Props {
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
onchange?: ChangeEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
let { value = $bindable(), min = 0, max = 100, step = 1, onchange = () => {} }: Props = $props();
|
||||
</script>
|
||||
|
||||
<input type="range" {min} {max} {step} bind:value class="slider" {onchange} />
|
||||
<span class="value-display">{value}</span>
|
||||
|
||||
<style>
|
||||
.slider {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--surface-control);
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--action-primary);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--action-primary);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.value-display {
|
||||
color: var(--text-main);
|
||||
font-weight: 500;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
padding: 4px 8px;
|
||||
background: var(--surface-main);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import type { ChangeEventHandler } from "svelte/elements";
|
||||
|
||||
interface Props {
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
onchange?: ChangeEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
let { value = $bindable(), min = 0, max = 100, step = 1, onchange = () => {} }: Props = $props();
|
||||
</script>
|
||||
|
||||
<input type="range" {min} {max} {step} bind:value class="slider" {onchange} />
|
||||
<span class="value-display">{value}</span>
|
||||
|
||||
<style>
|
||||
.slider {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--surface-control);
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--action-primary);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--action-primary);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.value-display {
|
||||
color: var(--text-main);
|
||||
font-weight: 500;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
padding: 4px 8px;
|
||||
background: var(--surface-main);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
value: string;
|
||||
}
|
||||
|
||||
let { value = $bindable() }: Props = $props();
|
||||
</script>
|
||||
|
||||
<select class="select-input" bind:value>
|
||||
<option value="Arial">Arial</option>
|
||||
<option value="Verdana">Verdana</option>
|
||||
<option value="Georgia">Georgia</option>
|
||||
<option value="Times New Roman">Times New Roman</option>
|
||||
<option value="Courier New">Courier New</option>
|
||||
<option value="Impact">Impact</option>
|
||||
<option value="Comic Sans MS">Comic Sans MS</option>
|
||||
<option value="Trebuchet MS">Trebuchet MS</option>
|
||||
</select>
|
||||
|
||||
<style>
|
||||
.select-input {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-main);
|
||||
color: var(--text-main);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.select-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
value: string;
|
||||
}
|
||||
|
||||
let { value = $bindable() }: Props = $props();
|
||||
</script>
|
||||
|
||||
<select class="select-input" bind:value>
|
||||
<option value="Arial">Arial</option>
|
||||
<option value="Verdana">Verdana</option>
|
||||
<option value="Georgia">Georgia</option>
|
||||
<option value="Times New Roman">Times New Roman</option>
|
||||
<option value="Courier New">Courier New</option>
|
||||
<option value="Impact">Impact</option>
|
||||
<option value="Comic Sans MS">Comic Sans MS</option>
|
||||
<option value="Trebuchet MS">Trebuchet MS</option>
|
||||
</select>
|
||||
|
||||
<style>
|
||||
.select-input {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-main);
|
||||
color: var(--text-main);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.select-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
</style>
|
||||
|
||||
+77
-77
@@ -1,77 +1,77 @@
|
||||
// Application Constants
|
||||
|
||||
// ===== PANEL SETTINGS =====
|
||||
export const PANEL_SETTINGS = {
|
||||
PANEL_WIDTH: 320,
|
||||
PANEL_HEIGHT_DEFAULT: 100,
|
||||
PANEL_HEIGHT_MAX: 200,
|
||||
|
||||
DEFAULT_BACKGROUND_IMAGE: "./backgrounds/b1.jpg",
|
||||
} as const;
|
||||
|
||||
// ===== TYPOGRAPHY =====
|
||||
export const TYPOGRAPHY = {
|
||||
FONT_FAMILY_DEFAULT: "Arial",
|
||||
FONT_FAMILIES: [
|
||||
"Arial",
|
||||
"Verdana",
|
||||
"Georgia",
|
||||
"Times New Roman",
|
||||
"Courier New",
|
||||
"Impact",
|
||||
"Comic Sans MS",
|
||||
"Trebuchet MS",
|
||||
],
|
||||
|
||||
// Font sizes for range inputs
|
||||
FONT_SIZE_MIN: 10,
|
||||
FONT_SIZE_MAX: 72,
|
||||
FONT_SIZE_DEFAULT: 32,
|
||||
|
||||
// Text limits
|
||||
MAX_TEXT_LENGTH: 100,
|
||||
|
||||
// Padding for range inputs
|
||||
PADDING_X_DEFAULT: 10,
|
||||
PADDING_X_MAX: 100,
|
||||
|
||||
// Vertical offset for range inputs
|
||||
VERTICAL_OFFSET_MAX: 100,
|
||||
VERTICAL_OFFSET_MIN: -100,
|
||||
|
||||
// Colors
|
||||
TEXT_COLOR_DEFAULT: "#ffffff",
|
||||
} as const;
|
||||
|
||||
// ===== IMAGE SETTINGS =====
|
||||
export const IMAGE_SETTINGS = {
|
||||
MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB
|
||||
|
||||
SUPPORTED_FORMATS: ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"] as const,
|
||||
} as const;
|
||||
|
||||
export const SlideDirection = {
|
||||
NEXT: "next",
|
||||
PREV: "prev",
|
||||
} as const;
|
||||
|
||||
export type SlideDirectionType = (typeof SlideDirection)[keyof typeof SlideDirection];
|
||||
|
||||
export const TextAlign = {
|
||||
LEFT: "left",
|
||||
CENTER: "center",
|
||||
RIGHT: "right",
|
||||
} as const;
|
||||
|
||||
export type TextAlignType = (typeof TextAlign)[keyof typeof TextAlign];
|
||||
|
||||
export const DEFAULT_TEXT_ALIGN: TextAlignType = TextAlign.CENTER;
|
||||
|
||||
export const Theme = {
|
||||
DARK: "dark",
|
||||
LIGHT: "light",
|
||||
} as const;
|
||||
|
||||
export type ThemeType = (typeof Theme)[keyof typeof Theme];
|
||||
|
||||
export const TRANSITION_DURATION = import.meta.env.MODE === "test" ? 0 : 300;
|
||||
// Application Constants
|
||||
|
||||
// ===== PANEL SETTINGS =====
|
||||
export const PANEL_SETTINGS = {
|
||||
PANEL_WIDTH: 320,
|
||||
PANEL_HEIGHT_DEFAULT: 100,
|
||||
PANEL_HEIGHT_MAX: 200,
|
||||
|
||||
DEFAULT_BACKGROUND_IMAGE: "./backgrounds/b1.jpg",
|
||||
} as const;
|
||||
|
||||
// ===== TYPOGRAPHY =====
|
||||
export const TYPOGRAPHY = {
|
||||
FONT_FAMILY_DEFAULT: "Arial",
|
||||
FONT_FAMILIES: [
|
||||
"Arial",
|
||||
"Verdana",
|
||||
"Georgia",
|
||||
"Times New Roman",
|
||||
"Courier New",
|
||||
"Impact",
|
||||
"Comic Sans MS",
|
||||
"Trebuchet MS",
|
||||
],
|
||||
|
||||
// Font sizes for range inputs
|
||||
FONT_SIZE_MIN: 10,
|
||||
FONT_SIZE_MAX: 72,
|
||||
FONT_SIZE_DEFAULT: 32,
|
||||
|
||||
// Text limits
|
||||
MAX_TEXT_LENGTH: 100,
|
||||
|
||||
// Padding for range inputs
|
||||
PADDING_X_DEFAULT: 10,
|
||||
PADDING_X_MAX: 100,
|
||||
|
||||
// Vertical offset for range inputs
|
||||
VERTICAL_OFFSET_MAX: 100,
|
||||
VERTICAL_OFFSET_MIN: -100,
|
||||
|
||||
// Colors
|
||||
TEXT_COLOR_DEFAULT: "#ffffff",
|
||||
} as const;
|
||||
|
||||
// ===== IMAGE SETTINGS =====
|
||||
export const IMAGE_SETTINGS = {
|
||||
MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB
|
||||
|
||||
SUPPORTED_FORMATS: ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"] as const,
|
||||
} as const;
|
||||
|
||||
export const SlideDirection = {
|
||||
NEXT: "next",
|
||||
PREV: "prev",
|
||||
} as const;
|
||||
|
||||
export type SlideDirectionType = (typeof SlideDirection)[keyof typeof SlideDirection];
|
||||
|
||||
export const TextAlign = {
|
||||
LEFT: "left",
|
||||
CENTER: "center",
|
||||
RIGHT: "right",
|
||||
} as const;
|
||||
|
||||
export type TextAlignType = (typeof TextAlign)[keyof typeof TextAlign];
|
||||
|
||||
export const DEFAULT_TEXT_ALIGN: TextAlignType = TextAlign.CENTER;
|
||||
|
||||
export const Theme = {
|
||||
DARK: "dark",
|
||||
LIGHT: "light",
|
||||
} as const;
|
||||
|
||||
export type ThemeType = (typeof Theme)[keyof typeof Theme];
|
||||
|
||||
export const TRANSITION_DURATION = import.meta.env.MODE === "test" ? 0 : 300;
|
||||
|
||||
+37
-37
@@ -1,37 +1,37 @@
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AppError";
|
||||
if (details) this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export class ImageError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "IMAGE_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
export class TextError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "TEXT_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
export class CanvasError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "CANVAS_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
export class StorageError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "STORAGE_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
export type ErrorType = AppError | ImageError | TextError | CanvasError | StorageError;
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AppError";
|
||||
if (details) this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export class ImageError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "IMAGE_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
export class TextError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "TEXT_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
export class CanvasError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "CANVAS_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
export class StorageError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "STORAGE_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
export type ErrorType = AppError | ImageError | TextError | CanvasError | StorageError;
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export type HexColor = `#${string}`;
|
||||
export type HexColor = `#${string}`;
|
||||
|
||||
+26
-26
@@ -1,26 +1,26 @@
|
||||
import { AppError } from "$lib/error.types";
|
||||
|
||||
export function formatError(error: unknown, defaultMessage: string = "Произошла ошибка"): string {
|
||||
if (error instanceof AppError || error instanceof Error) {
|
||||
return `${defaultMessage}: ${error.message}`;
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
return `${defaultMessage}: ${error}`;
|
||||
}
|
||||
|
||||
return `${defaultMessage}: Произошла неизвестная ошибка`;
|
||||
}
|
||||
|
||||
export function createError(message: string, code: string, details?: unknown): AppError {
|
||||
return new AppError(message, code, details);
|
||||
}
|
||||
|
||||
export function logError(error: unknown, context?: string): void {
|
||||
console.error("Error occurred:", {
|
||||
error,
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
import { AppError } from "$lib/error.types";
|
||||
|
||||
export function formatError(error: unknown, defaultMessage: string = "Произошла ошибка"): string {
|
||||
if (error instanceof AppError || error instanceof Error) {
|
||||
return `${defaultMessage}: ${error.message}`;
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
return `${defaultMessage}: ${error}`;
|
||||
}
|
||||
|
||||
return `${defaultMessage}: Произошла неизвестная ошибка`;
|
||||
}
|
||||
|
||||
export function createError(message: string, code: string, details?: unknown): AppError {
|
||||
return new AppError(message, code, details);
|
||||
}
|
||||
|
||||
export function logError(error: unknown, context?: string): void {
|
||||
console.error("Error occurred:", {
|
||||
error,
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
+35
-35
@@ -1,35 +1,35 @@
|
||||
<script lang="ts">
|
||||
import AppHeader from "$components/layout/AppHeader.svelte";
|
||||
import { PANEL_SETTINGS } from "$lib/constants";
|
||||
import { imageConfigState } from "$states/imageConfig.svelte";
|
||||
import { themeState } from "$states/theme.svelte";
|
||||
import { onMount, type Snippet } from "svelte";
|
||||
import "../app.css";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const newTheme = themeState.current;
|
||||
document.documentElement.setAttribute("data-theme", newTheme);
|
||||
});
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
onMount(async () => {
|
||||
await imageConfigState.uploadImageByLink(PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<AppHeader />
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import AppHeader from "$components/layout/AppHeader.svelte";
|
||||
import { PANEL_SETTINGS } from "$lib/constants";
|
||||
import { imageConfigState } from "$states/imageConfig.svelte";
|
||||
import { themeState } from "$states/theme.svelte";
|
||||
import { onMount, type Snippet } from "svelte";
|
||||
import "../app.css";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const newTheme = themeState.current;
|
||||
document.documentElement.setAttribute("data-theme", newTheme);
|
||||
});
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
onMount(async () => {
|
||||
await imageConfigState.uploadImageByLink(PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<AppHeader />
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const ssr = false;
|
||||
export const prerender = true;
|
||||
export const ssr = false;
|
||||
export const prerender = true;
|
||||
|
||||
+23
-23
@@ -1,23 +1,23 @@
|
||||
<script lang="ts">
|
||||
import PanelBar from "$components/layout/PanelBar.svelte";
|
||||
import TextBar from "$components/layout/TextBar.svelte";
|
||||
</script>
|
||||
|
||||
<div class="main-grid">
|
||||
<TextBar />
|
||||
<PanelBar />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.main-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
import PanelBar from "$components/layout/PanelBar.svelte";
|
||||
import TextBar from "$components/layout/TextBar.svelte";
|
||||
</script>
|
||||
|
||||
<div class="main-grid">
|
||||
<TextBar />
|
||||
<PanelBar />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.main-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,83 +1,83 @@
|
||||
import { ImageError } from "$lib/error.types";
|
||||
import { formatError, logError } from "$lib/utils/errorUtils";
|
||||
import { saveAs } from "file-saver";
|
||||
import JSZip from "jszip";
|
||||
import { Stage } from "konva/lib/Stage";
|
||||
|
||||
export type DownloadResult =
|
||||
| {
|
||||
success: true;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export interface DownloadItem {
|
||||
filename: string;
|
||||
stage: Stage;
|
||||
}
|
||||
|
||||
export class DownloadService {
|
||||
async downloadPanel(konvaStage: Stage, label: string): Promise<DownloadResult> {
|
||||
try {
|
||||
const blob = await this.stageToBlob(konvaStage);
|
||||
|
||||
const filename = `${label}.png`;
|
||||
saveAs(blob, filename);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logError(error, "Ошибка сохранения панели");
|
||||
return {
|
||||
success: false,
|
||||
error: formatError(error, "Ошибка сохранения панели"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async downloadAll(panels: Array<DownloadItem>): Promise<DownloadResult> {
|
||||
try {
|
||||
const zip = new JSZip();
|
||||
for (let panel of panels) {
|
||||
const blob = await this.stageToBlob(panel.stage);
|
||||
zip.file(`${panel.filename}.png`, blob);
|
||||
}
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: "blob" });
|
||||
saveAs(zipBlob, "panels.zip");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logError(error, "Ошибка сохранения архива");
|
||||
return {
|
||||
success: false,
|
||||
error: formatError(error, "Ошибка сохранения архива"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async stageToBlob(konvaStage: Stage): Promise<Blob> {
|
||||
if (!konvaStage || typeof konvaStage.toBlob !== "function") {
|
||||
throw new ImageError("Konva Stage не найден или не поддерживает toBlob");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
konvaStage.toBlob({
|
||||
callback: (blob: Blob | null) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new ImageError("Не удалось создать изображение из Konva Stage"));
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const downloadService = new DownloadService();
|
||||
import { ImageError } from "$lib/error.types";
|
||||
import { formatError, logError } from "$lib/utils/errorUtils";
|
||||
import { saveAs } from "file-saver";
|
||||
import JSZip from "jszip";
|
||||
import { Stage } from "konva/lib/Stage";
|
||||
|
||||
export type DownloadResult =
|
||||
| {
|
||||
success: true;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export interface DownloadItem {
|
||||
filename: string;
|
||||
stage: Stage;
|
||||
}
|
||||
|
||||
export class DownloadService {
|
||||
async downloadPanel(konvaStage: Stage, label: string): Promise<DownloadResult> {
|
||||
try {
|
||||
const blob = await this.stageToBlob(konvaStage);
|
||||
|
||||
const filename = `${label}.png`;
|
||||
saveAs(blob, filename);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logError(error, "Ошибка сохранения панели");
|
||||
return {
|
||||
success: false,
|
||||
error: formatError(error, "Ошибка сохранения панели"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async downloadAll(panels: Array<DownloadItem>): Promise<DownloadResult> {
|
||||
try {
|
||||
const zip = new JSZip();
|
||||
for (const panel of panels) {
|
||||
const blob = await this.stageToBlob(panel.stage);
|
||||
zip.file(`${panel.filename}.png`, blob);
|
||||
}
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: "blob" });
|
||||
saveAs(zipBlob, "panels.zip");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logError(error, "Ошибка сохранения архива");
|
||||
return {
|
||||
success: false,
|
||||
error: formatError(error, "Ошибка сохранения архива"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async stageToBlob(konvaStage: Stage): Promise<Blob> {
|
||||
if (!konvaStage || typeof konvaStage.toBlob !== "function") {
|
||||
throw new ImageError("Konva Stage не найден или не поддерживает toBlob");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
konvaStage.toBlob({
|
||||
callback: (blob: Blob | null) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new ImageError("Не удалось создать изображение из Konva Stage"));
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const downloadService = new DownloadService();
|
||||
|
||||
@@ -1,98 +1,98 @@
|
||||
export type ImageConfig = {
|
||||
image: HTMLImageElement | undefined;
|
||||
imageLink: string;
|
||||
imageReady: boolean;
|
||||
cropLeft: number;
|
||||
cropTop: number;
|
||||
cropRight: number;
|
||||
cropBottom: number;
|
||||
};
|
||||
|
||||
export class ImageConfigState {
|
||||
image = $state<HTMLImageElement | undefined>(undefined);
|
||||
imageLink = $state("");
|
||||
imageReady = $state(false);
|
||||
cropLeft = $state(0);
|
||||
cropTop = $state(0);
|
||||
cropRight = $state(0);
|
||||
cropBottom = $state(0);
|
||||
|
||||
#currentAbortController: AbortController | null = null;
|
||||
|
||||
private cleanup() {
|
||||
if (this.#currentAbortController) {
|
||||
this.#currentAbortController.abort();
|
||||
this.#currentAbortController = null;
|
||||
}
|
||||
|
||||
if (this.image) {
|
||||
this.image.onload = null;
|
||||
this.image.onerror = null;
|
||||
this.image.src = "";
|
||||
this.image = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async uploadImageByLink(link: string): Promise<void> {
|
||||
this.cleanup();
|
||||
|
||||
this.imageReady = false;
|
||||
this.imageLink = link;
|
||||
|
||||
this.#currentAbortController = new AbortController();
|
||||
const { signal } = this.#currentAbortController;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
|
||||
const onFinished = () => {
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
};
|
||||
|
||||
img.onload = () => {
|
||||
if (signal.aborted) return;
|
||||
onFinished();
|
||||
this.image = img;
|
||||
this.imageReady = true;
|
||||
resolve();
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
if (signal.aborted) return;
|
||||
onFinished();
|
||||
this.imageReady = false;
|
||||
reject(new Error(`Failed to load image: ${link}`));
|
||||
};
|
||||
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
onFinished();
|
||||
img.src = "";
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
img.src = link;
|
||||
});
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.cleanup();
|
||||
this.imageLink = "";
|
||||
this.imageReady = false;
|
||||
this.cropLeft = 0;
|
||||
this.cropTop = 0;
|
||||
this.cropRight = 0;
|
||||
this.cropBottom = 0;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
export const imageConfigState = new ImageConfigState();
|
||||
export type ImageConfig = {
|
||||
image: HTMLImageElement | undefined;
|
||||
imageLink: string;
|
||||
imageReady: boolean;
|
||||
cropLeft: number;
|
||||
cropTop: number;
|
||||
cropRight: number;
|
||||
cropBottom: number;
|
||||
};
|
||||
|
||||
export class ImageConfigState {
|
||||
image = $state<HTMLImageElement | undefined>(undefined);
|
||||
imageLink = $state("");
|
||||
imageReady = $state(false);
|
||||
cropLeft = $state(0);
|
||||
cropTop = $state(0);
|
||||
cropRight = $state(0);
|
||||
cropBottom = $state(0);
|
||||
|
||||
#currentAbortController: AbortController | null = null;
|
||||
|
||||
private cleanup() {
|
||||
if (this.#currentAbortController) {
|
||||
this.#currentAbortController.abort();
|
||||
this.#currentAbortController = null;
|
||||
}
|
||||
|
||||
if (this.image) {
|
||||
this.image.onload = null;
|
||||
this.image.onerror = null;
|
||||
this.image.src = "";
|
||||
this.image = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async uploadImageByLink(link: string): Promise<void> {
|
||||
this.cleanup();
|
||||
|
||||
this.imageReady = false;
|
||||
this.imageLink = link;
|
||||
|
||||
this.#currentAbortController = new AbortController();
|
||||
const { signal } = this.#currentAbortController;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
|
||||
const onFinished = () => {
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
};
|
||||
|
||||
img.onload = () => {
|
||||
if (signal.aborted) return;
|
||||
onFinished();
|
||||
this.image = img;
|
||||
this.imageReady = true;
|
||||
resolve();
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
if (signal.aborted) return;
|
||||
onFinished();
|
||||
this.imageReady = false;
|
||||
reject(new Error(`Failed to load image: ${link}`));
|
||||
};
|
||||
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
onFinished();
|
||||
img.src = "";
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
img.src = link;
|
||||
});
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.cleanup();
|
||||
this.imageLink = "";
|
||||
this.imageReady = false;
|
||||
this.cropLeft = 0;
|
||||
this.cropTop = 0;
|
||||
this.cropRight = 0;
|
||||
this.cropBottom = 0;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
export const imageConfigState = new ImageConfigState();
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import type { Stage } from "svelte-konva";
|
||||
|
||||
export const konvaAllStagesState: Array<Stage> = $state([]);
|
||||
import type { Stage } from "svelte-konva";
|
||||
|
||||
export const konvaAllStagesState: Array<Stage> = $state([]);
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { Stage } from "svelte-konva";
|
||||
|
||||
function createState() {
|
||||
let stage: Stage | undefined = $state(undefined);
|
||||
|
||||
return {
|
||||
get stage(): Stage | undefined {
|
||||
return stage;
|
||||
},
|
||||
set stage(newStage: Stage) {
|
||||
stage = newStage;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const konvaStageState = createState();
|
||||
import type { Stage } from "svelte-konva";
|
||||
|
||||
function createState() {
|
||||
let stage: Stage | undefined = $state(undefined);
|
||||
|
||||
return {
|
||||
get stage(): Stage | undefined {
|
||||
return stage;
|
||||
},
|
||||
set stage(newStage: Stage) {
|
||||
stage = newStage;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const konvaStageState = createState();
|
||||
|
||||
@@ -1,48 +1,44 @@
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
export const STATE_DATA = Symbol("state-data");
|
||||
export const DEBOUNCE_DURATION = import.meta.env.MODE === "test" ? 0 : 500;
|
||||
|
||||
type OnlyData<T> = {
|
||||
[K in keyof T as T[K] extends Function ? never : K]: T[K];
|
||||
};
|
||||
|
||||
export interface Persistable<D> {
|
||||
[STATE_DATA]: D;
|
||||
}
|
||||
|
||||
export function withPersistence<D extends object, T extends Persistable<D>>(
|
||||
key: string,
|
||||
state: T,
|
||||
debounceMs = DEBOUNCE_DURATION,
|
||||
): T {
|
||||
if (!browser) return state;
|
||||
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
|
||||
if (parsed) {
|
||||
state[STATE_DATA] = parsed;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error repairing state for ${key}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
$effect.root(() => {
|
||||
$effect(() => {
|
||||
const data = JSON.stringify($state.snapshot(state[STATE_DATA]));
|
||||
|
||||
if (debounceMs > 0) {
|
||||
const timeout = setTimeout(() => localStorage.setItem(key, data), debounceMs);
|
||||
return () => clearTimeout(timeout);
|
||||
} else {
|
||||
localStorage.setItem(key, data);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
export const STATE_DATA = Symbol("state-data");
|
||||
export const DEBOUNCE_DURATION = import.meta.env.MODE === "test" ? 0 : 500;
|
||||
|
||||
export interface Persistable<D> {
|
||||
[STATE_DATA]: D;
|
||||
}
|
||||
|
||||
export function withPersistence<D extends object, T extends Persistable<D>>(
|
||||
key: string,
|
||||
state: T,
|
||||
debounceMs = DEBOUNCE_DURATION,
|
||||
): T {
|
||||
if (!browser) return state;
|
||||
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
|
||||
if (parsed) {
|
||||
state[STATE_DATA] = parsed;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error repairing state for ${key}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
$effect.root(() => {
|
||||
$effect(() => {
|
||||
const data = JSON.stringify($state.snapshot(state[STATE_DATA]));
|
||||
|
||||
if (debounceMs > 0) {
|
||||
const timeout = setTimeout(() => localStorage.setItem(key, data), debounceMs);
|
||||
return () => clearTimeout(timeout);
|
||||
} else {
|
||||
localStorage.setItem(key, data);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -1,83 +1,83 @@
|
||||
import { type TextAlignType } from "$lib/constants";
|
||||
import type { HexColor } from "$lib/types";
|
||||
import { STATE_DATA, withPersistence } from "./persisted.svelte";
|
||||
|
||||
export type TextConfig = {
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
color: HexColor;
|
||||
align: TextAlignType;
|
||||
paddingX: number;
|
||||
offsetY: number;
|
||||
};
|
||||
|
||||
function createState() {
|
||||
const defaults: TextConfig = {
|
||||
fontSize: 24,
|
||||
fontFamily: "Arial",
|
||||
color: "#ffffff",
|
||||
align: "center",
|
||||
paddingX: 10,
|
||||
offsetY: 0,
|
||||
};
|
||||
let state: TextConfig = $state({ ...defaults });
|
||||
|
||||
return {
|
||||
get fontSize() {
|
||||
return state.fontSize;
|
||||
},
|
||||
set fontSize(value: number) {
|
||||
state.fontSize = value;
|
||||
},
|
||||
get fontFamily() {
|
||||
return state.fontFamily;
|
||||
},
|
||||
set fontFamily(fontFamily: string) {
|
||||
state.fontFamily = fontFamily;
|
||||
},
|
||||
get color() {
|
||||
return state.color;
|
||||
},
|
||||
set color(color: HexColor) {
|
||||
state.color = color;
|
||||
},
|
||||
get align() {
|
||||
return state.align;
|
||||
},
|
||||
set align(align: TextAlignType) {
|
||||
state.align = align;
|
||||
},
|
||||
get paddingX() {
|
||||
return state.paddingX;
|
||||
},
|
||||
set paddingX(paddingX: number) {
|
||||
state.paddingX = paddingX;
|
||||
},
|
||||
get offsetY() {
|
||||
return state.offsetY;
|
||||
},
|
||||
set offsetY(offsetY: number) {
|
||||
state.offsetY = offsetY;
|
||||
},
|
||||
get [STATE_DATA]() {
|
||||
return {
|
||||
fontSize: state.fontSize,
|
||||
fontFamily: state.fontFamily,
|
||||
color: state.color,
|
||||
align: state.align,
|
||||
paddingX: state.paddingX,
|
||||
offsetY: state.offsetY,
|
||||
};
|
||||
},
|
||||
set [STATE_DATA](newConfig: TextConfig) {
|
||||
state.fontSize = newConfig.fontSize;
|
||||
state.fontFamily = newConfig.fontFamily;
|
||||
state.color = newConfig.color;
|
||||
state.align = newConfig.align;
|
||||
state.paddingX = newConfig.paddingX;
|
||||
state.offsetY = newConfig.offsetY;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const textConfigState = withPersistence("text-config", createState());
|
||||
import { type TextAlignType } from "$lib/constants";
|
||||
import type { HexColor } from "$lib/types";
|
||||
import { STATE_DATA, withPersistence } from "./persisted.svelte";
|
||||
|
||||
export type TextConfig = {
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
color: HexColor;
|
||||
align: TextAlignType;
|
||||
paddingX: number;
|
||||
offsetY: number;
|
||||
};
|
||||
|
||||
function createState() {
|
||||
const defaults: TextConfig = {
|
||||
fontSize: 24,
|
||||
fontFamily: "Arial",
|
||||
color: "#ffffff",
|
||||
align: "center",
|
||||
paddingX: 10,
|
||||
offsetY: 0,
|
||||
};
|
||||
const state: TextConfig = $state({ ...defaults });
|
||||
|
||||
return {
|
||||
get fontSize() {
|
||||
return state.fontSize;
|
||||
},
|
||||
set fontSize(value: number) {
|
||||
state.fontSize = value;
|
||||
},
|
||||
get fontFamily() {
|
||||
return state.fontFamily;
|
||||
},
|
||||
set fontFamily(fontFamily: string) {
|
||||
state.fontFamily = fontFamily;
|
||||
},
|
||||
get color() {
|
||||
return state.color;
|
||||
},
|
||||
set color(color: HexColor) {
|
||||
state.color = color;
|
||||
},
|
||||
get align() {
|
||||
return state.align;
|
||||
},
|
||||
set align(align: TextAlignType) {
|
||||
state.align = align;
|
||||
},
|
||||
get paddingX() {
|
||||
return state.paddingX;
|
||||
},
|
||||
set paddingX(paddingX: number) {
|
||||
state.paddingX = paddingX;
|
||||
},
|
||||
get offsetY() {
|
||||
return state.offsetY;
|
||||
},
|
||||
set offsetY(offsetY: number) {
|
||||
state.offsetY = offsetY;
|
||||
},
|
||||
get [STATE_DATA]() {
|
||||
return {
|
||||
fontSize: state.fontSize,
|
||||
fontFamily: state.fontFamily,
|
||||
color: state.color,
|
||||
align: state.align,
|
||||
paddingX: state.paddingX,
|
||||
offsetY: state.offsetY,
|
||||
};
|
||||
},
|
||||
set [STATE_DATA](newConfig: TextConfig) {
|
||||
state.fontSize = newConfig.fontSize;
|
||||
state.fontFamily = newConfig.fontFamily;
|
||||
state.color = newConfig.color;
|
||||
state.align = newConfig.align;
|
||||
state.paddingX = newConfig.paddingX;
|
||||
state.offsetY = newConfig.offsetY;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const textConfigState = withPersistence("text-config", createState());
|
||||
|
||||
+43
-40
@@ -1,40 +1,43 @@
|
||||
import { STATE_DATA, withPersistence } from "./persisted.svelte";
|
||||
|
||||
export interface TextItem {
|
||||
text: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
const defaultTexts: Array<TextItem> = ["About me", "Links", "Projects"].map((text, idx) => ({ text, id: idx }));
|
||||
|
||||
export function createState() {
|
||||
let texts: Array<TextItem> = $state(defaultTexts);
|
||||
let nextId = $state(defaultTexts.length);
|
||||
|
||||
return {
|
||||
get texts() {
|
||||
return texts;
|
||||
},
|
||||
addText(text: string) {
|
||||
if (text.trim().length === 0) return;
|
||||
texts.push({ text, id: nextId });
|
||||
nextId++;
|
||||
},
|
||||
removeText(id: number) {
|
||||
texts = texts.filter((textItem) => textItem.id !== id);
|
||||
},
|
||||
clear() {
|
||||
texts = [];
|
||||
nextId = 0;
|
||||
},
|
||||
get [STATE_DATA]() {
|
||||
return texts.map(({ text }) => text);
|
||||
},
|
||||
set [STATE_DATA](newTexts: Array<string>) {
|
||||
texts = newTexts.map((text, idx) => ({ text, id: idx }));
|
||||
nextId = newTexts.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const textsState = withPersistence("texts", createState());
|
||||
import { STATE_DATA, withPersistence } from "./persisted.svelte";
|
||||
|
||||
export interface TextItem {
|
||||
text: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
const defaultTexts: Array<TextItem> = ["About me", "Links", "Projects"].map((text, idx) => ({
|
||||
text,
|
||||
id: idx,
|
||||
}));
|
||||
|
||||
export function createState() {
|
||||
let texts: Array<TextItem> = $state(defaultTexts);
|
||||
let nextId = $state(defaultTexts.length);
|
||||
|
||||
return {
|
||||
get texts() {
|
||||
return texts;
|
||||
},
|
||||
addText(text: string) {
|
||||
if (text.trim().length === 0) return;
|
||||
texts.push({ text, id: nextId });
|
||||
nextId++;
|
||||
},
|
||||
removeText(id: number) {
|
||||
texts = texts.filter((textItem) => textItem.id !== id);
|
||||
},
|
||||
clear() {
|
||||
texts = [];
|
||||
nextId = 0;
|
||||
},
|
||||
get [STATE_DATA]() {
|
||||
return texts.map(({ text }) => text);
|
||||
},
|
||||
set [STATE_DATA](newTexts: Array<string>) {
|
||||
texts = newTexts.map((text, idx) => ({ text, id: idx }));
|
||||
nextId = newTexts.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const textsState = withPersistence("texts", createState());
|
||||
|
||||
+28
-28
@@ -1,28 +1,28 @@
|
||||
import { Theme, type ThemeType } from "$lib/constants";
|
||||
import { STATE_DATA, withPersistence } from "./persisted.svelte";
|
||||
|
||||
function createState() {
|
||||
let current: ThemeType = $state(Theme.LIGHT);
|
||||
|
||||
return {
|
||||
get current() {
|
||||
return current;
|
||||
},
|
||||
set current(value: ThemeType) {
|
||||
current = value;
|
||||
},
|
||||
toggle() {
|
||||
current = current === Theme.DARK ? Theme.LIGHT : Theme.DARK;
|
||||
},
|
||||
get [STATE_DATA]() {
|
||||
return {
|
||||
current,
|
||||
};
|
||||
},
|
||||
set [STATE_DATA](data: { current: ThemeType }) {
|
||||
current = data.current;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const themeState = withPersistence("theme", createState());
|
||||
import { Theme, type ThemeType } from "$lib/constants";
|
||||
import { STATE_DATA, withPersistence } from "./persisted.svelte";
|
||||
|
||||
function createState() {
|
||||
let current: ThemeType = $state(Theme.LIGHT);
|
||||
|
||||
return {
|
||||
get current() {
|
||||
return current;
|
||||
},
|
||||
set current(value: ThemeType) {
|
||||
current = value;
|
||||
},
|
||||
toggle() {
|
||||
current = current === Theme.DARK ? Theme.LIGHT : Theme.DARK;
|
||||
},
|
||||
get [STATE_DATA]() {
|
||||
return {
|
||||
current,
|
||||
};
|
||||
},
|
||||
set [STATE_DATA](data: { current: ThemeType }) {
|
||||
current = data.current;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const themeState = withPersistence("theme", createState());
|
||||
|
||||
Reference in New Issue
Block a user