chore(themes): add new themes api v2, add in-app theme editor/creator, macos compatibility, fix asset loading, rework settings and setup wizard
This commit is contained in:
1 parent
417f54a94a
commit
87202de656
97 files changed
+10968
-1422
No files matched your search
@@ -0,0 +1,182 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
type ConfettiCannonProps = {
|
||||
colors?: string[];
|
||||
count?: number;
|
||||
};
|
||||
|
||||
let { colors = ['#ff006e', '#a78bfa', '#7dd3fc', '#fbbf24'], count = 820 }: ConfettiCannonProps =
|
||||
$props();
|
||||
|
||||
let canvas = $state<HTMLCanvasElement>();
|
||||
|
||||
type Particle = {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
spin: number;
|
||||
flutterPhase: number;
|
||||
flutterSpeed: number;
|
||||
swayPhase: number;
|
||||
swaySpeed: number;
|
||||
swayAmount: number;
|
||||
terminalVelocity: number;
|
||||
wind: number;
|
||||
color: string;
|
||||
delay: number;
|
||||
age: number;
|
||||
lifetime: number;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const element = canvas;
|
||||
if (!element) return;
|
||||
|
||||
const context = element.getContext('2d');
|
||||
if (!context) return;
|
||||
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
|
||||
const random = (min: number, max: number) => Math.random() * (max - min) + min;
|
||||
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let dpr = 1;
|
||||
let frame = 0;
|
||||
let lastTime = 0;
|
||||
let elapsed = 0;
|
||||
let particles: Particle[] = [];
|
||||
|
||||
const spawn = () => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
width = rect.width || window.innerWidth;
|
||||
height = rect.height || window.innerHeight;
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
|
||||
element.width = Math.max(1, Math.round(width * dpr));
|
||||
element.height = Math.max(1, Math.round(height * dpr));
|
||||
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
particles = Array.from({ length: count }, (_, index) => {
|
||||
const fromLeft = index % 2 === 0;
|
||||
const direction = fromLeft ? 1 : -1;
|
||||
const streamer = Math.random() < 0.35;
|
||||
const pieceWidth = streamer ? random(3, 5) : random(5, 8);
|
||||
const pieceHeight = streamer ? random(12, 24) : random(7, 14);
|
||||
|
||||
return {
|
||||
x: fromLeft ? random(0, 28) : width - random(0, 28),
|
||||
y: height - random(0, 16),
|
||||
// Aim inward and up from each bottom corner. Horizontal drag and
|
||||
// gravity turn the straight launch into a natural arc.
|
||||
vx: direction * random(160, 900),
|
||||
vy: -random(1050, 1450),
|
||||
width: pieceWidth,
|
||||
height: pieceHeight,
|
||||
rotation: random(0, Math.PI * 2),
|
||||
spin: random(-4, 4),
|
||||
flutterPhase: random(0, Math.PI * 2),
|
||||
flutterSpeed: random(2.5, 6.5),
|
||||
swayPhase: random(0, Math.PI * 2),
|
||||
swaySpeed: random(2.2, 4.2),
|
||||
swayAmount: random(4, 14),
|
||||
terminalVelocity: random(130, 230),
|
||||
wind: random(-8, 8),
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
delay: random(0, 0.25),
|
||||
age: 0,
|
||||
lifetime: random(7, 9.5),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const paint = (time: number) => {
|
||||
if (lastTime === 0) lastTime = time;
|
||||
const delta = Math.min((time - lastTime) / 1000, 0.05);
|
||||
lastTime = time;
|
||||
elapsed += delta;
|
||||
|
||||
context.clearRect(0, 0, width, height);
|
||||
|
||||
let alive = false;
|
||||
|
||||
for (const particle of particles) {
|
||||
if (elapsed < particle.delay) {
|
||||
alive = true;
|
||||
continue;
|
||||
}
|
||||
if (particle.age >= particle.lifetime) continue;
|
||||
|
||||
particle.age += delta;
|
||||
if (particle.age >= particle.lifetime) continue;
|
||||
alive = true;
|
||||
|
||||
particle.vx *= Math.exp(-0.5 * delta);
|
||||
|
||||
if (particle.vy < 0) {
|
||||
// The upward burst is slowed by gravity...
|
||||
particle.vy += 1400 * delta;
|
||||
} else {
|
||||
// ...then the paper drifts down at its own slow terminal speed
|
||||
// instead of accelerating like a solid object.
|
||||
particle.vy += (particle.terminalVelocity - particle.vy) * (1 - Math.exp(-2.6 * delta));
|
||||
}
|
||||
|
||||
particle.x += (particle.vx + particle.wind) * delta;
|
||||
particle.y += particle.vy * delta;
|
||||
particle.rotation += particle.spin * delta;
|
||||
|
||||
if (particle.x < -80 || particle.x > width + 80 || particle.y > height + 120) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fadeStart = particle.lifetime - 1.8;
|
||||
const alpha =
|
||||
particle.age > fadeStart ? Math.max(0, 1 - (particle.age - fadeStart) / 1.8) : 1;
|
||||
if (alpha <= 0) continue;
|
||||
|
||||
const flutter = Math.cos(particle.flutterPhase + particle.age * particle.flutterSpeed);
|
||||
const sway = Math.sin(particle.age * particle.swaySpeed + particle.swayPhase);
|
||||
const scaleX = 0.15 + Math.abs(flutter) * 0.85;
|
||||
|
||||
context.save();
|
||||
context.translate(particle.x + sway * particle.swayAmount, particle.y + sway * 3.5);
|
||||
context.rotate(particle.rotation + flutter * 0.5);
|
||||
context.scale(scaleX, 1);
|
||||
context.globalAlpha = alpha;
|
||||
context.fillStyle = particle.color;
|
||||
context.fillRect(
|
||||
-particle.width / 2,
|
||||
-particle.height / 2,
|
||||
particle.width,
|
||||
particle.height
|
||||
);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
if (alive) {
|
||||
frame = requestAnimationFrame(paint);
|
||||
} else {
|
||||
context.clearRect(0, 0, width, height);
|
||||
}
|
||||
};
|
||||
|
||||
spawn();
|
||||
frame = requestAnimationFrame(paint);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 z-20 h-full w-full"
|
||||
></canvas>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script module lang="ts">
|
||||
let previewCount = 0;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
family: string;
|
||||
src: string;
|
||||
weight?: string;
|
||||
style?: 'normal' | 'italic';
|
||||
text?: string;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let {
|
||||
family,
|
||||
src,
|
||||
weight = '400',
|
||||
style = 'normal',
|
||||
text = family,
|
||||
class: className = '',
|
||||
}: Props = $props();
|
||||
|
||||
let host = $state<HTMLSpanElement>();
|
||||
let visible = $state(false);
|
||||
let renderedFamily = $state<string>();
|
||||
let face: FontFace | undefined;
|
||||
|
||||
onMount(() => {
|
||||
if (!host) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!entries.some((entry) => entry.isIntersecting)) return;
|
||||
visible = true;
|
||||
observer.disconnect();
|
||||
},
|
||||
{ rootMargin: '180px 0px' }
|
||||
);
|
||||
observer.observe(host);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!visible || !src) return;
|
||||
let cancelled = false;
|
||||
const previewFamily = `EZPPSourcePreview_${previewCount++}`;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const candidate = new FontFace(previewFamily, `url("${src}")`, {
|
||||
weight,
|
||||
style,
|
||||
display: 'swap',
|
||||
});
|
||||
const loaded = await candidate.load();
|
||||
if (cancelled) return;
|
||||
document.fonts.add(loaded);
|
||||
face = loaded;
|
||||
renderedFamily = previewFamily;
|
||||
} catch {
|
||||
// A preview failure should leave the row readable in the fallback font.
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (face && face.family === previewFamily) {
|
||||
document.fonts.delete(face);
|
||||
face = undefined;
|
||||
}
|
||||
if (renderedFamily === previewFamily) renderedFamily = undefined;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={host}
|
||||
class={className}
|
||||
style:font-family={renderedFamily
|
||||
? `"${renderedFamily}", ui-sans-serif, system-ui, sans-serif`
|
||||
: undefined}
|
||||
style:font-weight={weight}
|
||||
style:font-style={style}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,392 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import Slider from '$lib/components/ui/slider/slider.svelte';
|
||||
import Textarea from '$lib/components/ui/textarea/textarea.svelte';
|
||||
import type { ThemeFontChoice } from '$lib/themeFonts';
|
||||
import type { ThemeV2Element } from '$lib/themeV2';
|
||||
import { Image as ImageIcon, RefreshCw, Type } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
element,
|
||||
fontOptions,
|
||||
onreplace,
|
||||
}: {
|
||||
element: ThemeV2Element;
|
||||
fontOptions: ThemeFontChoice[];
|
||||
onreplace: (kind: 'image' | 'video') => void;
|
||||
} = $props();
|
||||
|
||||
const field = 'h-8 border-theme-800 bg-theme-950 text-xs text-theme-50';
|
||||
const label = 'text-[10px] font-medium uppercase tracking-wide text-muted-foreground';
|
||||
</script>
|
||||
|
||||
<div class="space-y-4 p-4">
|
||||
<div class="flex items-center gap-2 border-b border-theme-800 pb-3">
|
||||
<div class="flex size-8 items-center justify-center rounded-md bg-primary/20 text-primary">
|
||||
{#if element.type === 'text'}
|
||||
<Type class="size-4" />
|
||||
{:else}
|
||||
<ImageIcon class="size-4" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-xs font-semibold capitalize text-theme-50">{element.type} layer</p>
|
||||
<p class="truncate font-mono text-[10px] text-muted-foreground">{element.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="element-x" class={label}>X</Label>
|
||||
<Input id="element-x" class={field} type="number" step="1" bind:value={element.x} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="element-y" class={label}>Y</Label>
|
||||
<Input id="element-y" class={field} type="number" step="1" bind:value={element.y} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="element-width" class={label}>Width</Label>
|
||||
<Input
|
||||
id="element-width"
|
||||
class={field}
|
||||
type="number"
|
||||
min="24"
|
||||
max="1000"
|
||||
bind:value={element.width}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="element-height" class={label}>Height</Label>
|
||||
<Input
|
||||
id="element-height"
|
||||
class={field}
|
||||
type="number"
|
||||
min="24"
|
||||
max="650"
|
||||
bind:value={element.height}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="element-rotation" class={label}>Rotation</Label>
|
||||
<Input
|
||||
id="element-rotation"
|
||||
class={field}
|
||||
type="number"
|
||||
min="-360"
|
||||
max="360"
|
||||
bind:value={element.rotation}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label class={label}>Opacity</Label>
|
||||
<span class="text-[10px] text-muted-foreground"
|
||||
>{Math.round((element.opacity ?? 1) * 100)}%</span
|
||||
>
|
||||
</div>
|
||||
<Slider
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={element.opacity ?? 1}
|
||||
onValueChange={(value) => (element.opacity = value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if element.type === 'text'}
|
||||
<div class="space-y-3 border-t border-theme-800 pt-3">
|
||||
<div class="space-y-1">
|
||||
<Label for="element-text" class={label}>Text</Label>
|
||||
<Textarea
|
||||
id="element-text"
|
||||
class="min-h-20 resize-y border-theme-800 bg-theme-950 text-xs normal-case tracking-normal text-theme-50"
|
||||
bind:value={element.text}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="element-font" class={label}>Font</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={element.fontFamily ?? 'Sora Variable'}
|
||||
onValueChange={(value) => {
|
||||
if (value) element.fontFamily = value;
|
||||
}}
|
||||
>
|
||||
<Select.Trigger
|
||||
id="element-font"
|
||||
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
||||
>
|
||||
{element.fontFamily ?? 'Sora Variable'}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="border-theme-800 bg-theme-950">
|
||||
{#each fontOptions as font (font.family)}
|
||||
<Select.Item value={font.family} label={`${font.label} · ${font.category}`} />
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="element-font-size" class={label}>Size</Label>
|
||||
<Input
|
||||
id="element-font-size"
|
||||
class={field}
|
||||
type="number"
|
||||
min="6"
|
||||
max="240"
|
||||
bind:value={element.fontSize}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="element-font-weight" class={label}>Weight</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={String(element.fontWeight ?? 400)}
|
||||
onValueChange={(value) => {
|
||||
if (value) element.fontWeight = Number(value);
|
||||
}}
|
||||
>
|
||||
<Select.Trigger
|
||||
id="element-font-weight"
|
||||
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
||||
>
|
||||
{element.fontWeight ?? 400}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="border-theme-800 bg-theme-950">
|
||||
{#each [100, 200, 300, 400, 500, 600, 700, 800, 900] as weight (weight)}
|
||||
<Select.Item value={String(weight)} label={String(weight)} />
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="element-align" class={label}>Align</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={element.align ?? 'left'}
|
||||
onValueChange={(value) => {
|
||||
if (value) element.align = value as 'left' | 'center' | 'right';
|
||||
}}
|
||||
>
|
||||
<Select.Trigger
|
||||
id="element-align"
|
||||
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
||||
>
|
||||
{element.align ?? 'left'}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="border-theme-800 bg-theme-950">
|
||||
<Select.Item value="left" label="Left" />
|
||||
<Select.Item value="center" label="Center" />
|
||||
<Select.Item value="right" label="Right" />
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="element-vertical-align" class={label}>Vertical</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={element.verticalAlign ?? 'top'}
|
||||
onValueChange={(value) => {
|
||||
if (value) element.verticalAlign = value as 'top' | 'center' | 'bottom';
|
||||
}}
|
||||
>
|
||||
<Select.Trigger
|
||||
id="element-vertical-align"
|
||||
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
||||
>
|
||||
{element.verticalAlign ?? 'top'}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="border-theme-800 bg-theme-950">
|
||||
<Select.Item value="top" label="Top" />
|
||||
<Select.Item value="center" label="Middle" />
|
||||
<Select.Item value="bottom" label="Bottom" />
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="element-line-height" class={label}>Line height</Label>
|
||||
<Input
|
||||
id="element-line-height"
|
||||
class={field}
|
||||
type="number"
|
||||
min="0.7"
|
||||
max="3"
|
||||
step="0.05"
|
||||
bind:value={element.lineHeight}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="element-letter-spacing" class={label}>Spacing</Label>
|
||||
<Input
|
||||
id="element-letter-spacing"
|
||||
class={field}
|
||||
type="number"
|
||||
min="-20"
|
||||
max="40"
|
||||
step="0.5"
|
||||
bind:value={element.letterSpacing}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="element-color" class={label}>Color</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
class="h-8 w-10 shrink-0 cursor-pointer border-theme-800 bg-theme-950 p-0.5"
|
||||
type="color"
|
||||
bind:value={element.color}
|
||||
/>
|
||||
<Input id="element-color" class="{field} flex-1 font-mono" bind:value={element.color} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if element.type === 'image' || element.type === 'video'}
|
||||
<div class="space-y-3 border-t border-theme-800 pt-3">
|
||||
<div class="rounded-md border border-theme-800 bg-black/20 p-2">
|
||||
<p class="text-[10px] uppercase tracking-wide text-muted-foreground">Asset</p>
|
||||
<p class="mt-1 truncate font-mono text-[10px] text-theme-100">{element.src}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="h-8 w-full gap-2 text-xs"
|
||||
onclick={() => onreplace(element.type as 'image' | 'video')}
|
||||
>
|
||||
<RefreshCw class="size-3.5" />
|
||||
Replace {element.type}
|
||||
</Button>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="element-fit" class={label}>Fit</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={element.fit ?? 'cover'}
|
||||
onValueChange={(value) => {
|
||||
if (value) element.fit = value as 'cover' | 'contain';
|
||||
}}
|
||||
>
|
||||
<Select.Trigger
|
||||
id="element-fit"
|
||||
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
||||
>
|
||||
{element.fit ?? 'cover'}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="border-theme-800 bg-theme-950">
|
||||
<Select.Item value="cover" label="Cover" />
|
||||
<Select.Item value="contain" label="Contain" />
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="element-radius" class={label}>Radius</Label>
|
||||
<Input
|
||||
id="element-radius"
|
||||
class={field}
|
||||
type="number"
|
||||
min="0"
|
||||
max="999"
|
||||
bind:value={element.radius}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if element.type === 'video'}
|
||||
<div class="flex items-start gap-2 rounded-md border border-theme-800 bg-black/20 p-2">
|
||||
<Checkbox
|
||||
id="element-muted"
|
||||
class="mt-0.5"
|
||||
checked={element.muted !== false}
|
||||
onCheckedChange={(checked) => {
|
||||
element.muted = checked === true;
|
||||
}}
|
||||
/>
|
||||
<div class="space-y-0.5">
|
||||
<Label for="element-muted" class="text-[10px] font-normal text-theme-100">
|
||||
Mute video
|
||||
</Label>
|
||||
<p class="text-[10px] leading-relaxed text-muted-foreground">
|
||||
Unmuted videos may wait for a click before they start, because of autoplay policies.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[10px] leading-relaxed text-muted-foreground">
|
||||
Videos autoplay and loop. WebM keeps theme packages small; MP4 also works.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3 border-t border-theme-800 pt-3">
|
||||
<div class="space-y-1">
|
||||
<Label for="element-shape" class={label}>Shape</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={element.shape}
|
||||
onValueChange={(value) => {
|
||||
if (value) element.shape = value as 'rectangle' | 'ellipse' | 'line';
|
||||
}}
|
||||
>
|
||||
<Select.Trigger
|
||||
id="element-shape"
|
||||
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
||||
>
|
||||
{element.shape}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="border-theme-800 bg-theme-950">
|
||||
<Select.Item value="rectangle" label="Rectangle" />
|
||||
<Select.Item value="ellipse" label="Ellipse" />
|
||||
<Select.Item value="line" label="Line" />
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="element-fill" class={label}>Fill</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
class="h-8 w-10 shrink-0 cursor-pointer border-theme-800 bg-theme-950 p-0.5"
|
||||
type="color"
|
||||
bind:value={element.fill}
|
||||
/>
|
||||
<Input id="element-fill" class="{field} flex-1 font-mono" bind:value={element.fill} />
|
||||
</div>
|
||||
</div>
|
||||
{#if element.shape !== 'ellipse'}
|
||||
<div class="space-y-1">
|
||||
<Label for="element-shape-radius" class={label}>Corner radius</Label>
|
||||
<Input
|
||||
id="element-shape-radius"
|
||||
class={field}
|
||||
type="number"
|
||||
min="0"
|
||||
max="999"
|
||||
bind:value={element.radius}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="element-border-width" class={label}>Border</Label>
|
||||
<Input
|
||||
id="element-border-width"
|
||||
class={field}
|
||||
type="number"
|
||||
min="0"
|
||||
max="40"
|
||||
bind:value={element.borderWidth}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="element-border-color" class={label}>Border color</Label>
|
||||
<Input
|
||||
id="element-border-color"
|
||||
class="h-8 w-full cursor-pointer border-theme-800 bg-theme-950 p-0.5"
|
||||
type="color"
|
||||
bind:value={element.borderColor}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
import Logo from '$assets/logo.png';
|
||||
import ThemeV2Background from '$lib/components/theme/ThemeV2Background.svelte';
|
||||
import ThemeV2Elements from '$lib/components/theme/ThemeV2Elements.svelte';
|
||||
import { fontStack } from '$lib/themeFonts';
|
||||
import { readableTextColor, themeFontFaceCss, type ThemeV2Definition } from '$lib/themeV2';
|
||||
import { Brush, House, Music, Paintbrush, Play, Settings, User, Wifi } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
definition,
|
||||
assets = '',
|
||||
assetOverrides = {},
|
||||
selectedId = undefined,
|
||||
interactive = false,
|
||||
scale = 1,
|
||||
onselect,
|
||||
onpatch,
|
||||
}: {
|
||||
definition: ThemeV2Definition;
|
||||
assets?: string;
|
||||
assetOverrides?: Record<string, string>;
|
||||
selectedId?: string;
|
||||
interactive?: boolean;
|
||||
scale?: number;
|
||||
onselect?: (id: string | undefined) => void;
|
||||
onpatch?: (id: string, patch: Record<string, unknown>) => void;
|
||||
} = $props();
|
||||
|
||||
const bodyFont = $derived(definition.typography?.body);
|
||||
const headingFont = $derived(definition.typography?.heading);
|
||||
const accent = $derived(definition.accentColor?.trim() || '#ff006e');
|
||||
const accentText = $derived(readableTextColor(accent));
|
||||
const stageStyle = $derived(
|
||||
[
|
||||
`font-family:${fontStack(bodyFont)}`,
|
||||
`--primary:${accent}`,
|
||||
`--primary-foreground:${accentText}`,
|
||||
`--ring:${accent}`,
|
||||
`--color-primary:${accent}`,
|
||||
`--color-primary-foreground:${accentText}`,
|
||||
`--color-primary-500:${accent}`,
|
||||
`--color-ring:${accent}`,
|
||||
].join(';')
|
||||
);
|
||||
const fontFaces = $derived(themeFontFaceCss(definition, assets, assetOverrides));
|
||||
const elements = $derived(definition.elements ?? []);
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html `<style>${fontFaces}</style>`}
|
||||
|
||||
<div
|
||||
class="theme-stage relative h-[650px] w-[1000px] overflow-hidden bg-theme-950 text-theme-50"
|
||||
style={stageStyle}
|
||||
role="presentation"
|
||||
onpointerdown={() => {
|
||||
if (interactive) onselect?.(undefined);
|
||||
}}
|
||||
>
|
||||
<ThemeV2Background {definition} {assets} {assetOverrides} preview />
|
||||
|
||||
<div class="relative z-10 grid h-full grid-cols-[0.085fr_1fr]">
|
||||
<aside
|
||||
class="flex flex-col items-center gap-2 border-r border-theme-900 bg-black/40 p-3 backdrop-blur-sm"
|
||||
>
|
||||
<div class="rounded-[1.1rem] bg-primary/30 p-1.5 ring-1 ring-inset ring-white/15">
|
||||
<img class="pointer-events-none size-[43px]" src={Logo} alt="" draggable="false" />
|
||||
</div>
|
||||
<span
|
||||
class="rounded-full bg-primary px-2 py-0 text-[8px] font-semibold text-primary-foreground"
|
||||
>
|
||||
3.2.7
|
||||
</span>
|
||||
<div
|
||||
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] bg-primary/50 p-3 ring-1 ring-inset ring-white/15"
|
||||
>
|
||||
<House class="size-5 text-theme-200" />
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] border border-black/20 bg-black/20 p-3 ring-1 ring-inset ring-white/15"
|
||||
>
|
||||
<Paintbrush class="size-5 text-theme-200" />
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] border border-black/20 bg-black/20 p-3 ring-1 ring-inset ring-white/15"
|
||||
>
|
||||
<Settings class="size-5 text-theme-200" />
|
||||
</div>
|
||||
<div
|
||||
class="mt-auto flex size-10 items-center justify-center overflow-hidden rounded-full bg-theme-900"
|
||||
>
|
||||
<User class="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="relative flex min-w-0 flex-col">
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="mt-12 mb-auto flex flex-col px-6">
|
||||
<span
|
||||
class="text-4xl font-bold drop-shadow-lg"
|
||||
style="font-family:{fontStack(headingFont, bodyFont)}"
|
||||
>
|
||||
EZPPLauncher
|
||||
</span>
|
||||
<span class="font-semibold text-muted-foreground drop-shadow-lg">Hello Guest!</span>
|
||||
</div>
|
||||
|
||||
<div class="relative h-50 shrink-0">
|
||||
<div
|
||||
class="absolute left-0 top-0 m-1 flex h-48 w-72 flex-col items-center justify-center gap-3 rounded-md bg-black/40 p-3 ring-1 ring-inset ring-white/5 backdrop-blur-sm"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-1 text-center">
|
||||
<span class="text-sm font-semibold text-theme-50">See your stats here</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
Log in with your EZPPFarm account to track your rank, PP, accuracy and playcount.
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div
|
||||
class="flex h-7 w-full items-center justify-center rounded-md bg-primary text-xs font-medium text-primary-foreground"
|
||||
>
|
||||
Log In
|
||||
</div>
|
||||
<div
|
||||
class="flex h-7 w-full items-center justify-center rounded-md border border-theme-800 text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Register
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative z-10 flex items-center justify-between border-t border-t-theme-900 bg-black/40 px-8 py-4 backdrop-blur-sm"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Music class="size-3.5 text-blue-400" />
|
||||
<span class="text-[11px] font-medium text-white/70">1.2k</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Brush class="size-3.5 text-amber-400" />
|
||||
<span class="text-[11px] font-medium text-white/70">42</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Wifi class="size-3.5 text-emerald-400" />
|
||||
<span class="text-[11px] font-medium text-white/70">19ms</span>
|
||||
</div>
|
||||
<div class="h-4 w-px bg-white/10"></div>
|
||||
<span
|
||||
class="rounded-full border border-white/6 bg-white/10 px-2 py-0.5 text-[10px] text-white/50"
|
||||
>
|
||||
stable
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full border border-white/6 bg-white/10 px-2 py-0.5 font-mono text-[10px] text-white/50"
|
||||
>
|
||||
20260101.0
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full border border-primary/20 bg-primary/20 px-2 py-0.5 text-[10px] text-primary"
|
||||
>
|
||||
Default skin
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex h-12 items-center rounded-full bg-primary shadow-lg shadow-primary/25">
|
||||
<div class="relative flex size-12 shrink-0 items-center justify-center">
|
||||
<div
|
||||
class="relative z-10 flex size-9 items-center justify-center rounded-full bg-black/30"
|
||||
>
|
||||
<Play class="ml-0.5 size-4 fill-current text-primary-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-52 pl-1 pr-6 text-center">
|
||||
<p class="text-sm font-bold leading-tight text-primary-foreground">Launch</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ThemeV2Elements
|
||||
{elements}
|
||||
{assets}
|
||||
{assetOverrides}
|
||||
{interactive}
|
||||
{selectedId}
|
||||
{scale}
|
||||
{onselect}
|
||||
{onpatch}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,251 @@
|
||||
<script lang="ts">
|
||||
import { custom_theme_volume } from '$lib/global';
|
||||
import { themeMediaSource } from '$lib/themeMedia';
|
||||
import type { ThemeV2Definition } from '$lib/themeV2';
|
||||
import { themeAssetUrl } from '$lib/themeV2';
|
||||
import { reduceAnimations } from '$lib/userSettings';
|
||||
import { trackMediaContainer } from '$lib/utils';
|
||||
|
||||
let {
|
||||
definition,
|
||||
assets = '',
|
||||
assetOverrides = {},
|
||||
preview = false,
|
||||
}: {
|
||||
definition: ThemeV2Definition;
|
||||
assets?: string;
|
||||
assetOverrides?: Record<string, string>;
|
||||
preview?: boolean;
|
||||
} = $props();
|
||||
|
||||
let audioElement = $state<HTMLAudioElement | undefined>(undefined);
|
||||
|
||||
const assetUrl = (path: string | undefined) => {
|
||||
if (!path) return '';
|
||||
return assetOverrides[path] ?? themeAssetUrl(assets, path);
|
||||
};
|
||||
|
||||
const background = $derived(definition.background);
|
||||
const overlay = $derived(definition.overlay);
|
||||
const effects = $derived(definition.effects);
|
||||
|
||||
const random = (seed: number) => {
|
||||
const value = Math.sin(seed * 12.9898) * 43758.5453;
|
||||
return value - Math.floor(value);
|
||||
};
|
||||
|
||||
const particles = $derived.by(() => {
|
||||
if ($reduceAnimations) return [];
|
||||
if (!effects || effects.type === 'none') return [];
|
||||
|
||||
const count = Math.min(120, Math.max(0, Math.round(effects.density ?? 30)));
|
||||
const speed = Math.max(0.2, effects.speed ?? 1);
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const value = (offset: number) => random(index + 1 + offset);
|
||||
const isStar = effects.type === 'stars';
|
||||
const size = isStar ? 1 + value(1) * 2.5 : 4 + value(1) * 7;
|
||||
const duration = (isStar ? 2 + value(2) * 3 : 9 + value(2) * 11) / speed;
|
||||
return {
|
||||
id: index,
|
||||
left: value(3) * 100,
|
||||
top: isStar ? value(4) * 100 : value(4) * 15,
|
||||
size,
|
||||
duration,
|
||||
delay: value(5) * duration,
|
||||
drift: -90 + value(6) * 180,
|
||||
opacity: 0.25 + value(7) * 0.65,
|
||||
rotate: value(8) * 360,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
function trackMedia(node: HTMLElement) {
|
||||
if (preview) return {};
|
||||
return trackMediaContainer(node);
|
||||
}
|
||||
|
||||
// `muted` is not reliably reflected after load, so keep the live property in
|
||||
// sync when the theme toggles it in the editor.
|
||||
function applyMuted(node: HTMLVideoElement, muted: boolean) {
|
||||
node.muted = muted;
|
||||
return {
|
||||
update(value: boolean) {
|
||||
node.muted = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!audioElement || !definition.audio) return;
|
||||
audioElement.volume = $custom_theme_volume;
|
||||
if (!preview) {
|
||||
audioElement.play().catch(() => {
|
||||
// Autoplay can be rejected until the window has been interacted with.
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="theme-v2 absolute inset-0 overflow-hidden" use:trackMedia>
|
||||
{#if background.type === 'color'}
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
style="background-color:{background.color};opacity:{background.opacity ?? 1}"
|
||||
></div>
|
||||
{:else if background.type === 'gradient'}
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
style="background-image:linear-gradient({background.angle ??
|
||||
135}deg, {background.from}, {background.to});opacity:{background.opacity ?? 1}"
|
||||
></div>
|
||||
{:else if background.type === 'image'}
|
||||
{#if background.src}
|
||||
<img
|
||||
class="absolute inset-0 h-full w-full"
|
||||
src={assetUrl(background.src)}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
style="object-fit:{background.fit ?? 'cover'};opacity:{background.opacity ??
|
||||
1};filter:{background.blur
|
||||
? `blur(${background.blur}px)`
|
||||
: 'none'};transform:{background.blur ? 'scale(1.05)' : 'none'};"
|
||||
/>
|
||||
{/if}
|
||||
{:else if background.type === 'video'}
|
||||
{#if background.src}
|
||||
<video
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
use:themeMediaSource={assetUrl(background.src)}
|
||||
autoplay
|
||||
loop
|
||||
muted={background.muted !== false}
|
||||
use:applyMuted={background.muted !== false}
|
||||
playsinline
|
||||
disablepictureinpicture
|
||||
style="object-fit:{background.fit ?? 'cover'};opacity:{background.opacity ??
|
||||
1};filter:{background.blur
|
||||
? `blur(${background.blur}px)`
|
||||
: 'none'};transform:{background.blur ? 'scale(1.05)' : 'none'};"
|
||||
></video>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if overlay && (overlay.opacity ?? 0) > 0}
|
||||
<div
|
||||
class="absolute inset-0 pointer-events-none"
|
||||
style="background-color:{overlay.color ?? '#000000'};opacity:{overlay.opacity ?? 0}"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#if overlay && (overlay.vignette ?? 0) > 0}
|
||||
<div
|
||||
class="vignette absolute inset-0 pointer-events-none"
|
||||
style="opacity:{overlay.vignette ?? 0}"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#if effects && effects.type !== 'none'}
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||
{#each particles as particle (particle.id)}
|
||||
<span
|
||||
class="particle {effects.type}"
|
||||
style="left:{particle.left}%;top:{particle.top}%;width:{particle.size}px;height:{particle.size}px;opacity:{particle.opacity};--particle-color:{effects.color ??
|
||||
'#ffffff'};--particle-drift:{particle.drift}px;--particle-duration:{particle.duration}s;--particle-delay:-{particle.delay}s;--particle-rotate:{particle.rotate}deg;animation-duration:{particle.duration}s;animation-delay:-{particle.delay}s;"
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if definition.audio && !preview}
|
||||
<audio
|
||||
bind:this={audioElement}
|
||||
use:themeMediaSource={assetUrl(definition.audio.src)}
|
||||
loop={definition.audio.loop !== false}
|
||||
autoplay
|
||||
></audio>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.vignette {
|
||||
background: radial-gradient(ellipse at center, transparent 38%, rgba(0, 0, 0, 0.92) 100%);
|
||||
}
|
||||
|
||||
.particle {
|
||||
position: absolute;
|
||||
display: block;
|
||||
background: var(--particle-color);
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
will-change: transform, opacity;
|
||||
|
||||
&.snow {
|
||||
border-radius: 9999px;
|
||||
filter: blur(0.5px);
|
||||
animation-name: v2-snow;
|
||||
}
|
||||
|
||||
&.petals {
|
||||
border-radius: 70% 0 70% 0;
|
||||
animation-name: v2-petals;
|
||||
}
|
||||
|
||||
&.bubbles {
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--particle-color);
|
||||
background: color-mix(in srgb, var(--particle-color) 16%, transparent);
|
||||
animation-name: v2-bubbles;
|
||||
}
|
||||
|
||||
&.stars {
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 0 6px var(--particle-color);
|
||||
animation-name: v2-stars;
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes v2-snow {
|
||||
from {
|
||||
transform: translate3d(0, -12vh, 0);
|
||||
}
|
||||
to {
|
||||
transform: translate3d(var(--particle-drift), 112vh, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes v2-petals {
|
||||
from {
|
||||
transform: translate3d(0, -12vh, 0) rotate(var(--particle-rotate));
|
||||
}
|
||||
50% {
|
||||
transform: translate3d(calc(var(--particle-drift) * 0.55), 50vh, 0)
|
||||
rotate(calc(var(--particle-rotate) + 180deg));
|
||||
}
|
||||
to {
|
||||
transform: translate3d(var(--particle-drift), 112vh, 0)
|
||||
rotate(calc(var(--particle-rotate) + 360deg));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes v2-bubbles {
|
||||
from {
|
||||
transform: translate3d(0, 110vh, 0);
|
||||
}
|
||||
to {
|
||||
transform: translate3d(var(--particle-drift), -12vh, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes v2-stars {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.15;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,244 @@
|
||||
<script lang="ts">
|
||||
import { fontStack } from '$lib/themeFonts';
|
||||
import { themeMediaSource } from '$lib/themeMedia';
|
||||
import { themeAssetUrl, type ThemeV2Element } from '$lib/themeV2';
|
||||
|
||||
let {
|
||||
elements = [],
|
||||
assets = '',
|
||||
assetOverrides = {},
|
||||
interactive = false,
|
||||
selectedId = undefined,
|
||||
scale = 1,
|
||||
onselect,
|
||||
onpatch,
|
||||
}: {
|
||||
elements?: ThemeV2Element[];
|
||||
assets?: string;
|
||||
assetOverrides?: Record<string, string>;
|
||||
interactive?: boolean;
|
||||
selectedId?: string;
|
||||
scale?: number;
|
||||
onselect?: (id: string | undefined) => void;
|
||||
onpatch?: (id: string, patch: Record<string, unknown>) => void;
|
||||
} = $props();
|
||||
|
||||
const assetUrl = (path: string) => assetOverrides[path] ?? themeAssetUrl(assets, path);
|
||||
|
||||
// Keep the live muted property in sync, including live editor toggles.
|
||||
function applyMuted(node: HTMLVideoElement, muted: boolean) {
|
||||
node.muted = muted;
|
||||
return {
|
||||
update(value: boolean) {
|
||||
node.muted = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type DragState = {
|
||||
id: string;
|
||||
startX: number;
|
||||
startY: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
};
|
||||
|
||||
type ResizeDirection = 'nw' | 'ne' | 'sw' | 'se';
|
||||
|
||||
type ResizeState = DragState & {
|
||||
direction: ResizeDirection;
|
||||
originWidth: number;
|
||||
originHeight: number;
|
||||
};
|
||||
|
||||
const resizeDirections: ResizeDirection[] = ['nw', 'ne', 'sw', 'se'];
|
||||
|
||||
let drag = $state<DragState | null>(null);
|
||||
let resize = $state<ResizeState | null>(null);
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
function beginDrag(event: PointerEvent, element: ThemeV2Element) {
|
||||
if (!interactive || (event.target as HTMLElement).dataset.resize) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onselect?.(element.id);
|
||||
drag = {
|
||||
id: element.id,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
originX: element.x,
|
||||
originY: element.y,
|
||||
};
|
||||
window.addEventListener('pointermove', moveDrag);
|
||||
window.addEventListener('pointerup', endDrag, { once: true });
|
||||
}
|
||||
|
||||
function moveDrag(event: PointerEvent) {
|
||||
if (!drag) return;
|
||||
const element = elements.find((candidate) => candidate.id === drag!.id);
|
||||
if (!element) return;
|
||||
const dx = (event.clientX - drag.startX) / scale;
|
||||
const dy = (event.clientY - drag.startY) / scale;
|
||||
onpatch?.(drag.id, {
|
||||
x: Math.round(clamp(drag.originX + dx, 0, 1000 - element.width)),
|
||||
y: Math.round(clamp(drag.originY + dy, 0, 650 - element.height)),
|
||||
});
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
drag = null;
|
||||
window.removeEventListener('pointermove', moveDrag);
|
||||
}
|
||||
|
||||
function beginResize(
|
||||
event: PointerEvent,
|
||||
element: ThemeV2Element,
|
||||
direction: ResizeState['direction']
|
||||
) {
|
||||
if (!interactive) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
resize = {
|
||||
id: element.id,
|
||||
direction,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
originX: element.x,
|
||||
originY: element.y,
|
||||
originWidth: element.width,
|
||||
originHeight: element.height,
|
||||
};
|
||||
window.addEventListener('pointermove', moveResize);
|
||||
window.addEventListener('pointerup', endResize, { once: true });
|
||||
}
|
||||
|
||||
function moveResize(event: PointerEvent) {
|
||||
if (!resize) return;
|
||||
const dx = (event.clientX - resize.startX) / scale;
|
||||
const dy = (event.clientY - resize.startY) / scale;
|
||||
const west = resize.direction.includes('w');
|
||||
const north = resize.direction.includes('n');
|
||||
const width = Math.round(clamp(resize.originWidth + (west ? -dx : dx), 24, 1000));
|
||||
const height = Math.round(clamp(resize.originHeight + (north ? -dy : dy), 24, 650));
|
||||
onpatch?.(resize.id, {
|
||||
x: west
|
||||
? Math.round(clamp(resize.originX + resize.originWidth - width, 0, 1000 - width))
|
||||
: resize.originX,
|
||||
y: north
|
||||
? Math.round(clamp(resize.originY + resize.originHeight - height, 0, 650 - height))
|
||||
: resize.originY,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
|
||||
function endResize() {
|
||||
resize = null;
|
||||
window.removeEventListener('pointermove', moveResize);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 z-20 {interactive ? 'pointer-events-auto' : 'pointer-events-none'}"
|
||||
role="presentation"
|
||||
>
|
||||
{#each elements as element (element.id)}
|
||||
{@const isSelected = interactive && element.id === selectedId}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div
|
||||
class="absolute select-none"
|
||||
class:cursor-move={interactive}
|
||||
style="left:{element.x}px;top:{element.y}px;width:{element.width}px;height:{element.height}px;transform:rotate({element.rotation ??
|
||||
0}deg);opacity:{element.opacity ?? 1};touch-action:none;outline:{isSelected
|
||||
? '2px solid #ff006e'
|
||||
: 'none'};outline-offset:2px;"
|
||||
role={interactive ? 'button' : undefined}
|
||||
tabindex={interactive ? 0 : -1}
|
||||
aria-label={interactive ? `Select ${element.type} layer` : undefined}
|
||||
onpointerdown={(event) => beginDrag(event, element)}
|
||||
onkeydown={(event) => {
|
||||
if (interactive && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault();
|
||||
onselect?.(element.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if element.type === 'text'}
|
||||
<div
|
||||
class="h-full w-full overflow-hidden"
|
||||
style="display:flex;align-items:{element.verticalAlign === 'center'
|
||||
? 'center'
|
||||
: element.verticalAlign === 'bottom'
|
||||
? 'flex-end'
|
||||
: 'flex-start'};justify-content:{element.align === 'center'
|
||||
? 'center'
|
||||
: element.align === 'right'
|
||||
? 'flex-end'
|
||||
: 'flex-start'};text-align:{element.align ?? 'left'};"
|
||||
>
|
||||
<span
|
||||
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
|
||||
28}px;font-weight:{element.fontWeight ?? 600};color:{element.color ??
|
||||
'#ffffff'};line-height:{element.lineHeight ??
|
||||
1.2};letter-spacing:{element.letterSpacing ??
|
||||
0}px;white-space:pre-wrap;overflow-wrap:anywhere;"
|
||||
>
|
||||
{element.text}
|
||||
</span>
|
||||
</div>
|
||||
{:else if element.type === 'image'}
|
||||
<img
|
||||
class="pointer-events-none h-full w-full"
|
||||
src={assetUrl(element.src)}
|
||||
alt=""
|
||||
draggable="false"
|
||||
style="object-fit:{element.fit ?? 'cover'};border-radius:{element.radius ?? 0}px;"
|
||||
/>
|
||||
{:else if element.type === 'video'}
|
||||
<video
|
||||
class="pointer-events-none h-full w-full"
|
||||
use:themeMediaSource={assetUrl(element.src)}
|
||||
autoplay
|
||||
loop
|
||||
muted={element.muted !== false}
|
||||
use:applyMuted={element.muted !== false}
|
||||
playsinline
|
||||
disablepictureinpicture
|
||||
aria-hidden="true"
|
||||
style="object-fit:{element.fit ?? 'cover'};border-radius:{element.radius ?? 0}px;"
|
||||
></video>
|
||||
{:else}
|
||||
<div
|
||||
class="h-full w-full"
|
||||
style="background:{element.fill ?? '#ff006e'};border-radius:{element.shape === 'ellipse'
|
||||
? '50%'
|
||||
: `${element.radius ?? 16}px`};border:{element.borderWidth
|
||||
? `${element.borderWidth}px solid ${element.borderColor ?? '#ffffff'}`
|
||||
: 'none'};"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#if isSelected}
|
||||
{#each resizeDirections as direction (direction)}
|
||||
<span
|
||||
class="absolute z-10 block size-3 rounded-full border-2 border-white bg-primary shadow"
|
||||
data-resize={direction}
|
||||
style="{direction.includes('n') ? 'top:-7px' : 'bottom:-7px'};{direction.includes('w')
|
||||
? 'left:-7px'
|
||||
: 'right:-7px'};cursor:{direction === 'nw' || direction === 'se'
|
||||
? 'nwse-resize'
|
||||
: 'nesw-resize'};"
|
||||
onpointerdown={(event) => beginResize(event, element, direction)}
|
||||
role="presentation"
|
||||
></span>
|
||||
{/each}
|
||||
<span
|
||||
class="absolute -top-7 left-0 rounded bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground shadow"
|
||||
>
|
||||
{element.type}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,10 +0,0 @@
|
||||
<script lang="ts">
|
||||
const prop: { beatmapId: number } = $props();
|
||||
</script>
|
||||
|
||||
<div class="absolute w-screen h-screen overflow-hidden pointer-events-none rounded">
|
||||
<div
|
||||
style="background: url(https://osu.direct/api/media/background/{prop.beatmapId})"
|
||||
class="absolute top-0 left-0 w-full h-full !bg-cover -z-10 pointer-events-none blur opacity-10 rounded"
|
||||
></div>
|
||||
</div>
|
||||
@@ -53,6 +53,8 @@
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<!-- href is a pass-through prop and may point to an external URL -->
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a bind:this={ref} class={cn(buttonVariants({ variant, size }), className)} {href} {...restProps}>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils';
|
||||
import { Heart } from '@lucide/svelte';
|
||||
|
||||
interface Sparkle {
|
||||
id: string;
|
||||
x: string;
|
||||
y: string;
|
||||
color: string;
|
||||
delay: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
let {
|
||||
className,
|
||||
colors = {
|
||||
first: '#9E7AFF',
|
||||
second: '#FE8BBB',
|
||||
},
|
||||
count = 10,
|
||||
}: {
|
||||
className: string;
|
||||
colors?: { first: string; second: string };
|
||||
count?: number;
|
||||
} = $props();
|
||||
|
||||
// Helper to check if two sparkles are too close
|
||||
function isTooClose(x1: number, y1: number, x2: number, y2: number, minDist: number) {
|
||||
const dx = x1 - x2;
|
||||
const dy = y1 - y2;
|
||||
return Math.sqrt(dx * dx + dy * dy) < minDist;
|
||||
}
|
||||
|
||||
// Generate sparkles with minimum distance between them
|
||||
let sparkles: Sparkle[] = [];
|
||||
const minDistance = 25; // in percentage points (adjust as needed)
|
||||
|
||||
let attempts = 0;
|
||||
while (sparkles.length < count && attempts < count * 20) {
|
||||
attempts++;
|
||||
let starX = Math.random() * 100;
|
||||
let starY = Math.random() * 100;
|
||||
let tooClose = sparkles.some((s) =>
|
||||
isTooClose(Number.parseFloat(s.x), Number.parseFloat(s.y), starX, starY, minDistance)
|
||||
);
|
||||
if (tooClose) continue;
|
||||
|
||||
let color = Math.random() > 0.5 ? colors.first : colors.second;
|
||||
let delay = Math.random() * 3;
|
||||
let scale = Math.random() * 1 + 0.3;
|
||||
let id = `${starX}-${starY}-${Date.now()}-${Math.random()}`;
|
||||
sparkles.push({
|
||||
id,
|
||||
x: `${starX}%`,
|
||||
y: `${starY}%`,
|
||||
color,
|
||||
delay,
|
||||
scale,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={cn('relative', className)}>
|
||||
<span class={cn('relative inline-block', className)}>
|
||||
{#each sparkles as item (item.id)}
|
||||
<div
|
||||
class="sparkle pointer-events-none absolute z-20 top-0 left-0"
|
||||
id={item.id}
|
||||
style="left: {item.x}; top: {item.y}; color: {item.color}; animation-delay: {item.delay}s; transform: scale({item.scale});"
|
||||
>
|
||||
<!-- <svg width="13" height="13" viewBox="0 0 21 21">
|
||||
<path
|
||||
d="M9.82531 0.843845C10.0553 0.215178 10.9446 0.215178 11.1746 0.843845L11.8618 2.72026C12.4006 4.19229 12.3916 6.39157 13.5 7.5C14.6084 8.60843 16.8077 8.59935 18.2797 9.13822L20.1561 9.82534C20.7858 10.0553 20.7858 10.9447 20.1561 11.1747L18.2797 11.8618C16.8077 12.4007 14.6084 12.3916 13.5 13.5C12.3916 14.6084 12.4006 16.8077 11.8618 18.2798L11.1746 20.1562C10.9446 20.7858 10.0553 20.7858 9.82531 20.1562L9.13819 18.2798C8.59932 16.8077 8.60843 14.6084 7.5 13.5C6.39157 12.3916 4.19225 12.4007 2.72023 11.8618L0.843814 11.1747C0.215148 10.9447 0.215148 10.0553 0.843814 9.82534L2.72023 9.13822C4.19225 8.59935 6.39157 8.60843 7.5 7.5C8.60843 6.39157 8.59932 4.19229 9.13819 2.72026L9.82531 0.843845Z"
|
||||
fill={item.color}
|
||||
/>
|
||||
</svg> -->
|
||||
<Heart fill={item.color} size={14} />
|
||||
</div>
|
||||
{/each}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.sparkle {
|
||||
animation: sparkle-fade 1.2s linear infinite;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes sparkle-fade {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0) rotate(-50deg);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
80% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0) rotate(50deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { Slider as SliderPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(0),
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1,
|
||||
disabled = false,
|
||||
class: className,
|
||||
onValueChange,
|
||||
onValueCommit,
|
||||
}: {
|
||||
ref?: HTMLSpanElement | null;
|
||||
value?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
disabled?: boolean;
|
||||
class?: string;
|
||||
onValueChange?: (value: number) => void;
|
||||
onValueCommit?: (value: number) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<SliderPrimitive.Root
|
||||
bind:ref
|
||||
type="single"
|
||||
bind:value
|
||||
{min}
|
||||
{max}
|
||||
{step}
|
||||
{disabled}
|
||||
{onValueChange}
|
||||
{onValueCommit}
|
||||
class={cn(
|
||||
'bg-primary/20 relative flex h-1.5 w-full touch-none select-none items-center rounded-full',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range class="bg-primary absolute h-full rounded-full" />
|
||||
<SliderPrimitive.Thumb
|
||||
index={0}
|
||||
class="border-primary bg-background ring-offset-background focus-visible:ring-ring block size-4 rounded-full border-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
</SliderPrimitive.Root>
|
||||
@@ -1 +0,0 @@
|
||||
export { default as Toaster } from './sonner.svelte';
|
||||
@@ -1,21 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { mode } from 'mode-watcher';
|
||||
import { Toaster as Sonner, type ToasterProps as SonnerProps } from 'svelte-sonner';
|
||||
|
||||
let { ...restProps }: SonnerProps = $props();
|
||||
</script>
|
||||
|
||||
<Sonner
|
||||
theme={mode.current}
|
||||
class="toaster group !z-[1000]"
|
||||
toastOptions={{
|
||||
classes: {
|
||||
toast:
|
||||
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
|
||||
description: 'group-[.toast]:text-muted-foreground',
|
||||
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
|
||||
},
|
||||
}}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -1,7 +0,0 @@
|
||||
import Root from './switch.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Switch,
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { Switch as SwitchPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
checked = $bindable(false),
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
|
||||
</script>
|
||||
|
||||
<SwitchPrimitive.Root
|
||||
bind:ref
|
||||
bind:checked
|
||||
class={cn(
|
||||
'focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
class={cn(
|
||||
'bg-background pointer-events-none block size-5 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { WithElementRef } from 'bits-ui';
|
||||
import type { HTMLTextareaAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLTextareaAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<textarea
|
||||
bind:this={ref}
|
||||
class={cn(
|
||||
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-20 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
bind:value
|
||||
{...restProps}
|
||||
></textarea>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { config } from '$lib/config';
|
||||
import { custom_theme_audio_playing, custom_theme_volume } from '$lib/global';
|
||||
import { custom_theme_audio_playing, custom_theme_volume, platform } from '$lib/global';
|
||||
import { reduceAnimations } from '$lib/userSettings';
|
||||
import { Volume, Volume1, VolumeOff } from '@lucide/svelte';
|
||||
import Minimize from '@lucide/svelte/icons/minus';
|
||||
@@ -19,7 +19,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div data-tauri-drag-region class="titlebar z-[100]">
|
||||
<div data-tauri-drag-region class="titlebar z-[100]" class:macos-window={$platform === 'macos'}>
|
||||
{#if $custom_theme_audio_playing}
|
||||
{#if $custom_theme_audio_playing}
|
||||
<div class="relative flex items-center justify-center group">
|
||||
@@ -115,6 +115,11 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Keep the window controls clear of the rounded top-right corner. */
|
||||
.titlebar.macos-window {
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
|
||||
Reference in new issue
Block a user