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,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>
|
||||
Reference in new issue
Block a user