feat(themes): polish theme editor, updates, and card motion

- Lazy-load the theme editor, workshop, diagnostics, logs, and presence editor.
- Check installed themes for newer workshop versions and support updates with backup/restore.
- Preserve accordion state for the current editor session and add section icons.
- Animate installed and workshop cards on insertion, and fix the duplicate presence idle action.
This commit is contained in:
HorizonCode committed 2026-11-04 09:54:58 +01:00
1 parent 51f5fa3e46
commit d56ed9d786
16 files changed
+720 -434

No files matched your search

+3 -2
View File
@@ -951,11 +951,12 @@ export async function reviewCommunityTheme(
*/
export async function downloadAndInstallCommunityTheme(
theme: CommunityTheme,
onProgress?: (status: string, percentage: number) => void
onProgress?: (status: string, percentage: number) => void,
options?: { folderName?: string }
): Promise<{ success: boolean; folderName: string }> {
const { onProgress: reportProgress = () => {} } = { onProgress };
const baseThemesDir = await themesDirectory();
const folderName = theme.id;
const folderName = options?.folderName?.trim() || theme.id;
const targetFolder = await path.join(baseThemesDir, folderName);
reportProgress('Downloading theme package...', 10);
@@ -51,6 +51,7 @@
import { normalizeThemeTags } from '$lib/themeV2';
import { reloadThemes, updateInstalledThemeTags, type Theme } from '$lib/themes';
import { currentUser } from '$lib/userAuthentication';
import { reduceAnimations } from '$lib/userSettings';
import WorkshopTooltip from './WorkshopTooltip.svelte';
import {
Archive,
@@ -87,6 +88,7 @@
import { sileo } from 'sileo';
import { onDestroy, onMount } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { fade } from 'svelte/transition';
let {
onApplyTheme,
@@ -2051,7 +2053,7 @@
</div>
{:else}
<div class="grid grid-cols-3 gap-3 pb-4">
{#each themes as theme (theme.id)}
{#each themes as theme, index (theme.id)}
{@const localTheme =
localThemeMap.get(theme.id.toLowerCase()) ||
localThemeMap.get(theme.name.toLowerCase())}
@@ -2077,6 +2079,9 @@
class="group flex flex-col overflow-hidden rounded-xl border bg-theme-900/30 {isActive
? 'border-primary/60 ring-1 ring-primary/20'
: 'border-theme-800/60'} transition-all duration-300 hover:-translate-y-0.5 hover:border-white/20 hover:bg-theme-900/50 hover:shadow-xl hover:shadow-black/30"
class:workshop-card-enter={!$reduceAnimations}
style="animation-delay: {Math.min(index * 24, 144)}ms"
out:fade={{ duration: $reduceAnimations ? 0 : 140 }}
>
<!-- 16:9 Thumbnail -->
<div
@@ -4481,3 +4486,20 @@
</AlertDialog.Content>
</AlertDialog.Root>
</Tooltip.Provider>
<style>
.workshop-card-enter {
animation: workshop-card-in 240ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
@keyframes workshop-card-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
@@ -110,6 +110,8 @@
import { formatBytes, openURL } from '$lib/utils';
import {
AlertTriangle,
AlignCenter,
AlignHorizontalSpaceAround,
Boxes,
Check,
CheckCircle2,
@@ -128,12 +130,14 @@
Download,
Eye,
EyeOff,
FileText,
Film,
Gauge,
Globe,
GripVertical,
Hand,
Image as ImageIcon,
Info,
Keyboard,
Layers,
LayoutGrid,
@@ -5075,7 +5079,12 @@
</div>
{:else}
<div class="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
<ThemeSection title="Theme details" bordered={false} contentClass="space-y-3 pt-2">
<ThemeSection
title="Theme details"
icon={Info}
bordered={false}
contentClass="space-y-3 pt-2"
>
<div class="space-y-1.5">
<Label for="theme-name" class={labelClass}>Name</Label>
<Input
@@ -5234,7 +5243,11 @@
onchange={setVariableDefault}
/>
<ThemeSection title="Launcher Layout & Chrome" contentClass="space-y-3 pt-2.5">
<ThemeSection
title="Launcher Layout & Chrome"
icon={LayoutGrid}
contentClass="space-y-3 pt-2.5"
>
<div class="space-y-1.5">
<Label class={labelClass}>Layout Preset</Label>
<div class="grid grid-cols-3 gap-1 rounded-md bg-black/30 p-1">
@@ -5419,7 +5432,7 @@
</div>
</ThemeSection>
<ThemeSection title="Background" contentClass="space-y-3 pt-2.5">
<ThemeSection title="Background" icon={ImageIcon} contentClass="space-y-3 pt-2.5">
<div class="grid grid-cols-4 gap-1 rounded-md bg-black/30 p-1">
{#each backgroundTypes as option (option.value)}
<Button
@@ -6230,6 +6243,7 @@
<div class="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
<ThemeSection
title="Align"
icon={AlignCenter}
bordered={false}
headingClass="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
contentClass="space-y-2 pt-2"
@@ -6275,6 +6289,7 @@
</ThemeSection>
<ThemeSection
title="Distribute"
icon={AlignHorizontalSpaceAround}
headingClass="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
contentClass="space-y-2 pt-2.5"
>
@@ -6298,6 +6313,7 @@
</ThemeSection>
<ThemeSection
title="Layers"
icon={Layers}
headingClass="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
contentClass="space-y-2 pt-2.5"
>
@@ -6621,6 +6637,7 @@
<ThemeSection
title="Theme summary"
icon={FileText}
class="mt-4"
bordered={false}
contentClass="space-y-2 pt-2"
@@ -6649,6 +6666,7 @@
<ThemeSection
title="Shortcuts"
icon={Keyboard}
class="mt-4"
bordered={false}
open={false}
@@ -38,12 +38,15 @@
Film,
Gauge,
Image as ImageIcon,
Layers,
LayoutGrid,
Megaphone,
MousePointerClick,
Move,
Play,
RefreshCw,
Repeat,
Scan,
Shapes,
Sparkles,
Spline,
@@ -221,7 +224,7 @@
</div>
</div>
<ThemeSection title="Position & size" bordered={false} contentClass="space-y-3 pt-2">
<ThemeSection title="Position & size" icon={Move} bordered={false} contentClass="space-y-3 pt-2">
<div class="space-y-1">
<Label for="element-name" class={label}>Layer name</Label>
<Input
@@ -290,6 +293,7 @@
<ThemeSection
title="Layer state"
icon={Layers}
contentClass="flex flex-wrap items-center gap-x-4 gap-y-2 pt-2.5"
>
<div class="flex items-center gap-1.5">
@@ -402,7 +406,11 @@
/>
{#if element.type === 'text' || element.type === 'data'}
<ThemeSection title={element.type === 'data' ? 'Data' : 'Text'} contentClass="space-y-3 pt-2.5">
<ThemeSection
title={element.type === 'data' ? 'Data' : 'Text'}
icon={Type}
contentClass="space-y-3 pt-2.5"
>
{#if element.type === 'data'}
<div class="space-y-1.5">
<Label for="element-data-field" class={label}>Value</Label>
@@ -695,7 +703,7 @@
<ThemeTextStyleControls {element} onchange={(patch) => Object.assign(element, patch)} />
</ThemeSection>
{:else if element.type === 'svelte'}
<ThemeSection title="Svelte component" contentClass="space-y-3 pt-2.5">
<ThemeSection title="Svelte component" icon={Component} contentClass="space-y-3 pt-2.5">
<div
class="rounded-md border border-amber-500/25 bg-amber-500/10 p-2 text-[9px] leading-relaxed text-amber-100"
>
@@ -721,7 +729,7 @@
</div>
</ThemeSection>
{:else if element.type === 'image' || element.type === 'video'}
<ThemeSection title="Media" contentClass="space-y-3 pt-2.5">
<ThemeSection title="Media" icon={ImageIcon} contentClass="space-y-3 pt-2.5">
<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>
@@ -829,7 +837,7 @@
{/if}
</ThemeSection>
{:else if element.type === 'lottie'}
<ThemeSection title="Lottie animation" contentClass="space-y-3 pt-2.5">
<ThemeSection title="Lottie animation" icon={Film} contentClass="space-y-3 pt-2.5">
<div class="rounded-md border border-theme-800 bg-black/20 p-2">
<p class="text-[10px] uppercase tracking-wide text-muted-foreground">Animation</p>
<p class="mt-1 truncate font-mono text-[10px] text-theme-100">{element.src}</p>
@@ -885,7 +893,7 @@
</p>
</ThemeSection>
{:else if element.type === 'shape'}
<ThemeSection title="Shape" contentClass="space-y-3 pt-2.5">
<ThemeSection title="Shape" icon={Shapes} contentClass="space-y-3 pt-2.5">
<div class="space-y-1">
<Label for="element-shape" class={label}>Shape</Label>
<Select.Root
@@ -1153,7 +1161,7 @@
</div>
</ThemeSection>
{:else if element.type === 'icon'}
<ThemeSection title="Icon" contentClass="space-y-3 pt-2.5">
<ThemeSection title="Icon" icon={Sparkles} contentClass="space-y-3 pt-2.5">
<div class="space-y-1">
<Label for="element-icon" class={label}>Icon</Label>
<ThemeIconPicker bind:value={element.icon} {iconSet} />
@@ -1185,7 +1193,7 @@
</div>
</ThemeSection>
{:else if element.type === 'repeater'}
<ThemeSection title="Repeater" contentClass="space-y-3 pt-2.5">
<ThemeSection title="Repeater" icon={Repeat} contentClass="space-y-3 pt-2.5">
<div class="space-y-1">
<Label for="element-repeater-source" class={label}>Tile</Label>
<Select.Root
@@ -1307,7 +1315,7 @@
</p>
</ThemeSection>
{:else if element.type === 'gauge'}
<ThemeSection title="Gauge" contentClass="space-y-3 pt-2.5">
<ThemeSection title="Gauge" icon={Gauge} contentClass="space-y-3 pt-2.5">
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label for="element-gauge-mode" class={label}>Style</Label>
@@ -1423,7 +1431,7 @@
</div>
</ThemeSection>
{:else if element.type === 'ticker'}
<ThemeSection title="Ticker" contentClass="space-y-3 pt-2.5">
<ThemeSection title="Ticker" icon={Type} contentClass="space-y-3 pt-2.5">
<div class="space-y-1">
<Label for="element-ticker-text" class={label}>Text</Label>
<Textarea
@@ -1520,7 +1528,7 @@
{/if}
</ThemeSection>
{:else if element.type === 'button'}
<ThemeSection title="Button" contentClass="space-y-3 pt-2.5">
<ThemeSection title="Button" icon={MousePointerClick} contentClass="space-y-3 pt-2.5">
<div class="space-y-1">
<Label for="element-button-label" class={label}>Label</Label>
<Input id="element-button-label" class={field} bind:value={element.label} />
@@ -1704,7 +1712,7 @@
</div>
</ThemeSection>
{:else if element.type === 'path'}
<ThemeSection title="Vector path" contentClass="space-y-3 pt-2.5">
<ThemeSection title="Vector path" icon={Spline} contentClass="space-y-3 pt-2.5">
<div class="space-y-1">
<Label for="element-path-data" class={label}>SVG path data</Label>
<Textarea
@@ -1850,7 +1858,7 @@
</div>
</ThemeSection>
{:else if element.type === 'particles'}
<ThemeSection title="Particles" contentClass="space-y-3 pt-2.5">
<ThemeSection title="Particles" icon={Sparkles} contentClass="space-y-3 pt-2.5">
<div class="space-y-1">
<Label for="element-particles-preset" class={label}>Preset</Label>
<Select.Root
@@ -1954,7 +1962,7 @@
</p>
</ThemeSection>
{:else if element.type === 'clock'}
<ThemeSection title="Clock" contentClass="space-y-3 pt-2.5">
<ThemeSection title="Clock" icon={Clock} contentClass="space-y-3 pt-2.5">
<div class="space-y-1">
<Label for="element-clock-mode" class={label}>Style</Label>
<Select.Root
@@ -2121,7 +2129,7 @@
</p>
</ThemeSection>
{:else if element.type === 'countdown'}
<ThemeSection title="Countdown" contentClass="space-y-3 pt-2.5">
<ThemeSection title="Countdown" icon={Timer} contentClass="space-y-3 pt-2.5">
<div class="space-y-1.5">
<Label for="element-countdown-target" class={label}>Target date & time</Label>
<DatePicker
@@ -2220,7 +2228,7 @@
</div>
</ThemeSection>
{:else if element.type === 'widget'}
<ThemeSection title="Widget" contentClass="space-y-3 pt-2.5">
<ThemeSection title="Widget" icon={LayoutGrid} contentClass="space-y-3 pt-2.5">
<div class="space-y-1.5">
<Label for="element-widget-type" class={label}>Widget Type</Label>
<Select.Root
@@ -2558,7 +2566,7 @@
</ThemeSection>
{/if}
<ThemeSection title="Mask" contentClass="space-y-3 pt-2.5">
<ThemeSection title="Mask" icon={Scan} contentClass="space-y-3 pt-2.5">
<div class="space-y-1">
<Label for="element-mask-shape" class={label}>Clip shape</Label>
<Select.Root
@@ -3,6 +3,7 @@
import Button from '$lib/components/ui/button/button.svelte';
import Slider from '$lib/components/ui/slider/slider.svelte';
import type { ThemeV2Filters } from '$lib/themeV2';
import { SlidersHorizontal } from '@lucide/svelte';
let {
filters,
@@ -67,6 +68,7 @@
<ThemeSection
title="Filters"
icon={SlidersHorizontal}
open={false}
actions={filterActions}
contentClass="space-y-2.5 pt-2.5"
@@ -17,7 +17,7 @@
type ThemeV2Group,
type ThemeV2MaskShape,
} from '$lib/themeV2';
import { Boxes, RefreshCw, Ungroup } from '@lucide/svelte';
import { Boxes, Layers, RefreshCw, Scan, Ungroup } from '@lucide/svelte';
let {
group = $bindable(),
@@ -58,7 +58,7 @@
</div>
</div>
<ThemeSection title="Group" bordered={false} contentClass="space-y-3 pt-2">
<ThemeSection title="Group" icon={Boxes} bordered={false} contentClass="space-y-3 pt-2">
<div class="space-y-1">
<Label for="group-name" class={label}>Group name</Label>
<Input
@@ -96,6 +96,7 @@
<ThemeSection
title="Group state"
icon={Layers}
contentClass="flex flex-wrap items-center gap-x-4 gap-y-2 pt-2.5"
>
<div class="flex items-center gap-1.5">
@@ -167,7 +168,7 @@
ondelete={ondeleterule}
/>
<ThemeSection title="Mask" contentClass="space-y-3 pt-2.5">
<ThemeSection title="Mask" icon={Scan} contentClass="space-y-3 pt-2.5">
<div class="space-y-1">
<Label for="group-mask-shape" class={label}>Clip shape</Label>
<Select.Root
@@ -1,6 +1,7 @@
<script lang="ts">
import ThemeBehaviorInspector from '$lib/components/theme/ThemeBehaviorInspector.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import { themeEditorSectionOpen } from '$lib/themeEditorUiState';
import { themeBehaviorSummary, themeGroupInteractiveCount } from '$lib/themeLogic';
import type {
ThemeV2Behavior,
@@ -30,8 +31,13 @@
ondelete: (id: string) => void;
} = $props();
let open = $state(false);
const sectionKey = 'Logic';
let open = $state(themeEditorSectionOpen.get(sectionKey) ?? false);
let selectedId = $state<string | undefined>(undefined);
$effect(() => {
themeEditorSectionOpen.set(sectionKey, open);
});
let section = $state<HTMLElement>();
let lastFocusToken = 0;
+13 -5
View File
@@ -1,10 +1,12 @@
<script lang="ts">
import { themeEditorSectionOpen } from '$lib/themeEditorUiState';
import { ChevronDown } from '@lucide/svelte';
import { untrack } from 'svelte';
import type { Component, Snippet } from 'svelte';
let {
title,
open = $bindable(true),
open: initialOpen = true,
icon: Icon,
badge,
description,
@@ -27,6 +29,12 @@
headingClass?: string;
bordered?: boolean;
} = $props();
let isOpen = $state(untrack(() => themeEditorSectionOpen.get(title) ?? initialOpen));
$effect(() => {
themeEditorSectionOpen.set(title, isOpen);
});
</script>
<section class="{bordered ? 'border-t border-theme-800 pt-3 ' : ''}{className}">
@@ -34,8 +42,8 @@
<button
type="button"
class="group flex min-w-0 flex-1 items-center gap-1.5 rounded-md py-0.5 text-left transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
aria-expanded={open}
onclick={() => (open = !open)}
aria-expanded={isOpen}
onclick={() => (isOpen = !isOpen)}
>
{#if Icon}
<Icon class="size-3.5 shrink-0 text-primary" />
@@ -49,7 +57,7 @@
</span>
{/if}
<ChevronDown
class="ml-auto size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 {open
class="ml-auto size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 {isOpen
? 'rotate-180'
: ''}"
/>
@@ -61,7 +69,7 @@
{/if}
</div>
{#if open}
{#if isOpen}
<div class={contentClass}>
{#if description}
<p class="text-[10px] leading-relaxed text-muted-foreground">{description}</p>
@@ -5,7 +5,7 @@
import Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select';
import type { ThemeV2TextDecoration, ThemeV2TextStyle, ThemeV2TextTransform } from '$lib/themeV2';
import { Bold, Italic, RotateCcw, Strikethrough, Underline } from '@lucide/svelte';
import { Bold, Italic, RotateCcw, Strikethrough, Type, Underline } from '@lucide/svelte';
let {
element,
@@ -89,6 +89,7 @@
<ThemeSection
title="Text style"
icon={Type}
open={false}
actions={textStyleActions}
contentClass="space-y-2 pt-2.5"
@@ -112,6 +112,7 @@
<ThemeSection
title="Transform"
icon={RotateCw}
open={false}
actions={transformActions}
contentClass="space-y-2 pt-2.5"
@@ -3,7 +3,7 @@
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import type { ThemeV2ScriptValue } from '$lib/themeV2';
import { RotateCcw } from '@lucide/svelte';
import { Braces, RotateCcw } from '@lucide/svelte';
let {
variables,
@@ -38,7 +38,12 @@
};
</script>
<ThemeSection title="Variables" badge={variables.length} contentClass="space-y-2 pt-2">
<ThemeSection
title="Variables"
icon={Braces}
badge={variables.length}
contentClass="space-y-2 pt-2"
>
<p class="text-[10px] leading-relaxed text-muted-foreground">
Starting values for
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">{'{name}'}</code>
+2 -2
View File
@@ -125,8 +125,8 @@ export const DISCORD_PRESENCE_PRESETS: DiscordPresencePreset[] = [
},
button2: {
enabled: true,
label: 'View Profile',
url: '{profileUrl}',
label: 'Join EZPPFarm',
url: 'https://ez-pp.farm/discord',
},
},
},
+8
View File
@@ -0,0 +1,8 @@
import { SvelteMap } from 'svelte/reactivity';
/**
* Open state for accordion sections in the current theme-editor WebView session.
* This module is re-evaluated when the editor window closes, so the state is not
* persisted across app launches.
*/
export const themeEditorSectionOpen = new SvelteMap<string, boolean>();
+3 -2
View File
@@ -522,6 +522,7 @@ export const reloadThemes = async () => {
if (ACTIVE_STATUSES.has(storeTheme.status)) return storeTheme;
const theme = installedMap.get(storeTheme.name);
if (!theme) return storeTheme;
const versionChanged = storeTheme.version !== theme.version;
// Replace the object rather than mutating it so the theme cards see the
// new values when the list is rendered from a filtered derived array.
@@ -538,8 +539,8 @@ export const reloadThemes = async () => {
nsfw: theme.nsfw,
workshopId: theme.workshopId,
definition: theme.definition,
updateAvailable: theme.updateAvailable,
availableVersion: theme.availableVersion,
updateAvailable: versionChanged ? false : storeTheme.updateAvailable,
availableVersion: versionChanged ? undefined : storeTheme.availableVersion,
cssStyle: theme.cssStyle,
backups: theme.backups ?? storeTheme.backups,
};
+12 -4
View File
@@ -1,8 +1,6 @@
<script lang="ts">
import { browser } from '$app/environment';
import Logo from '$assets/logo.png';
import DiscordPresenceEditor from '$lib/components/presence/DiscordPresenceEditor.svelte';
import ThemeEditorWindow from '$lib/components/theme/ThemeEditorWindow.svelte';
import ThemeV2Background from '$lib/components/theme/ThemeV2Background.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import AnimatedBg from '$lib/components/ui/animated-bg/AnimatedBg.svelte';
@@ -449,9 +447,19 @@
/>
{#if editorWindow}
<ThemeEditorWindow />
{#await import('$lib/components/theme/ThemeEditorWindow.svelte')}
<div class="h-screen w-screen bg-theme-950"></div>
{:then editorModule}
{@const ThemeEditorWindow = editorModule.default}
<ThemeEditorWindow />
{/await}
{:else if presenceEditorWindow}
<DiscordPresenceEditor standalone />
{#await import('$lib/components/presence/DiscordPresenceEditor.svelte')}
<div class="h-screen w-screen bg-theme-950"></div>
{:then presenceModule}
{@const DiscordPresenceEditor = presenceModule.default}
<DiscordPresenceEditor standalone />
{/await}
{:else}
{#if editorOpen}
<div
+587 -391
View File
File diff suppressed because it is too large. Load diff