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
+354 -68

No files matched your search

+3 -2
View File
@@ -951,11 +951,12 @@ export async function reviewCommunityTheme(
*/ */
export async function downloadAndInstallCommunityTheme( export async function downloadAndInstallCommunityTheme(
theme: CommunityTheme, theme: CommunityTheme,
onProgress?: (status: string, percentage: number) => void onProgress?: (status: string, percentage: number) => void,
options?: { folderName?: string }
): Promise<{ success: boolean; folderName: string }> { ): Promise<{ success: boolean; folderName: string }> {
const { onProgress: reportProgress = () => {} } = { onProgress }; const { onProgress: reportProgress = () => {} } = { onProgress };
const baseThemesDir = await themesDirectory(); const baseThemesDir = await themesDirectory();
const folderName = theme.id; const folderName = options?.folderName?.trim() || theme.id;
const targetFolder = await path.join(baseThemesDir, folderName); const targetFolder = await path.join(baseThemesDir, folderName);
reportProgress('Downloading theme package...', 10); reportProgress('Downloading theme package...', 10);
@@ -51,6 +51,7 @@
import { normalizeThemeTags } from '$lib/themeV2'; import { normalizeThemeTags } from '$lib/themeV2';
import { reloadThemes, updateInstalledThemeTags, type Theme } from '$lib/themes'; import { reloadThemes, updateInstalledThemeTags, type Theme } from '$lib/themes';
import { currentUser } from '$lib/userAuthentication'; import { currentUser } from '$lib/userAuthentication';
import { reduceAnimations } from '$lib/userSettings';
import WorkshopTooltip from './WorkshopTooltip.svelte'; import WorkshopTooltip from './WorkshopTooltip.svelte';
import { import {
Archive, Archive,
@@ -87,6 +88,7 @@
import { sileo } from 'sileo'; import { sileo } from 'sileo';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { fade } from 'svelte/transition';
let { let {
onApplyTheme, onApplyTheme,
@@ -2051,7 +2053,7 @@
</div> </div>
{:else} {:else}
<div class="grid grid-cols-3 gap-3 pb-4"> <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 = {@const localTheme =
localThemeMap.get(theme.id.toLowerCase()) || localThemeMap.get(theme.id.toLowerCase()) ||
localThemeMap.get(theme.name.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 class="group flex flex-col overflow-hidden rounded-xl border bg-theme-900/30 {isActive
? 'border-primary/60 ring-1 ring-primary/20' ? '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" : '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 --> <!-- 16:9 Thumbnail -->
<div <div
@@ -4481,3 +4486,20 @@
</AlertDialog.Content> </AlertDialog.Content>
</AlertDialog.Root> </AlertDialog.Root>
</Tooltip.Provider> </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 { formatBytes, openURL } from '$lib/utils';
import { import {
AlertTriangle, AlertTriangle,
AlignCenter,
AlignHorizontalSpaceAround,
Boxes, Boxes,
Check, Check,
CheckCircle2, CheckCircle2,
@@ -128,12 +130,14 @@
Download, Download,
Eye, Eye,
EyeOff, EyeOff,
FileText,
Film, Film,
Gauge, Gauge,
Globe, Globe,
GripVertical, GripVertical,
Hand, Hand,
Image as ImageIcon, Image as ImageIcon,
Info,
Keyboard, Keyboard,
Layers, Layers,
LayoutGrid, LayoutGrid,
@@ -5075,7 +5079,12 @@
</div> </div>
{:else} {:else}
<div class="min-h-0 flex-1 space-y-3 overflow-y-auto p-3"> <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"> <div class="space-y-1.5">
<Label for="theme-name" class={labelClass}>Name</Label> <Label for="theme-name" class={labelClass}>Name</Label>
<Input <Input
@@ -5234,7 +5243,11 @@
onchange={setVariableDefault} 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"> <div class="space-y-1.5">
<Label class={labelClass}>Layout Preset</Label> <Label class={labelClass}>Layout Preset</Label>
<div class="grid grid-cols-3 gap-1 rounded-md bg-black/30 p-1"> <div class="grid grid-cols-3 gap-1 rounded-md bg-black/30 p-1">
@@ -5419,7 +5432,7 @@
</div> </div>
</ThemeSection> </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"> <div class="grid grid-cols-4 gap-1 rounded-md bg-black/30 p-1">
{#each backgroundTypes as option (option.value)} {#each backgroundTypes as option (option.value)}
<Button <Button
@@ -6230,6 +6243,7 @@
<div class="min-h-0 flex-1 space-y-3 overflow-y-auto p-3"> <div class="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
<ThemeSection <ThemeSection
title="Align" title="Align"
icon={AlignCenter}
bordered={false} bordered={false}
headingClass="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground" headingClass="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
contentClass="space-y-2 pt-2" contentClass="space-y-2 pt-2"
@@ -6275,6 +6289,7 @@
</ThemeSection> </ThemeSection>
<ThemeSection <ThemeSection
title="Distribute" title="Distribute"
icon={AlignHorizontalSpaceAround}
headingClass="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground" headingClass="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
contentClass="space-y-2 pt-2.5" contentClass="space-y-2 pt-2.5"
> >
@@ -6298,6 +6313,7 @@
</ThemeSection> </ThemeSection>
<ThemeSection <ThemeSection
title="Layers" title="Layers"
icon={Layers}
headingClass="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground" headingClass="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
contentClass="space-y-2 pt-2.5" contentClass="space-y-2 pt-2.5"
> >
@@ -6621,6 +6637,7 @@
<ThemeSection <ThemeSection
title="Theme summary" title="Theme summary"
icon={FileText}
class="mt-4" class="mt-4"
bordered={false} bordered={false}
contentClass="space-y-2 pt-2" contentClass="space-y-2 pt-2"
@@ -6649,6 +6666,7 @@
<ThemeSection <ThemeSection
title="Shortcuts" title="Shortcuts"
icon={Keyboard}
class="mt-4" class="mt-4"
bordered={false} bordered={false}
open={false} open={false}
@@ -38,12 +38,15 @@
Film, Film,
Gauge, Gauge,
Image as ImageIcon, Image as ImageIcon,
Layers,
LayoutGrid, LayoutGrid,
Megaphone, Megaphone,
MousePointerClick, MousePointerClick,
Move,
Play, Play,
RefreshCw, RefreshCw,
Repeat, Repeat,
Scan,
Shapes, Shapes,
Sparkles, Sparkles,
Spline, Spline,
@@ -221,7 +224,7 @@
</div> </div>
</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"> <div class="space-y-1">
<Label for="element-name" class={label}>Layer name</Label> <Label for="element-name" class={label}>Layer name</Label>
<Input <Input
@@ -290,6 +293,7 @@
<ThemeSection <ThemeSection
title="Layer state" title="Layer state"
icon={Layers}
contentClass="flex flex-wrap items-center gap-x-4 gap-y-2 pt-2.5" contentClass="flex flex-wrap items-center gap-x-4 gap-y-2 pt-2.5"
> >
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
@@ -402,7 +406,11 @@
/> />
{#if element.type === 'text' || element.type === 'data'} {#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'} {#if element.type === 'data'}
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="element-data-field" class={label}>Value</Label> <Label for="element-data-field" class={label}>Value</Label>
@@ -695,7 +703,7 @@
<ThemeTextStyleControls {element} onchange={(patch) => Object.assign(element, patch)} /> <ThemeTextStyleControls {element} onchange={(patch) => Object.assign(element, patch)} />
</ThemeSection> </ThemeSection>
{:else if element.type === 'svelte'} {: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 <div
class="rounded-md border border-amber-500/25 bg-amber-500/10 p-2 text-[9px] leading-relaxed text-amber-100" 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> </div>
</ThemeSection> </ThemeSection>
{:else if element.type === 'image' || element.type === 'video'} {: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"> <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="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> <p class="mt-1 truncate font-mono text-[10px] text-theme-100">{element.src}</p>
@@ -829,7 +837,7 @@
{/if} {/if}
</ThemeSection> </ThemeSection>
{:else if element.type === 'lottie'} {: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"> <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="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> <p class="mt-1 truncate font-mono text-[10px] text-theme-100">{element.src}</p>
@@ -885,7 +893,7 @@
</p> </p>
</ThemeSection> </ThemeSection>
{:else if element.type === 'shape'} {: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"> <div class="space-y-1">
<Label for="element-shape" class={label}>Shape</Label> <Label for="element-shape" class={label}>Shape</Label>
<Select.Root <Select.Root
@@ -1153,7 +1161,7 @@
</div> </div>
</ThemeSection> </ThemeSection>
{:else if element.type === 'icon'} {: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"> <div class="space-y-1">
<Label for="element-icon" class={label}>Icon</Label> <Label for="element-icon" class={label}>Icon</Label>
<ThemeIconPicker bind:value={element.icon} {iconSet} /> <ThemeIconPicker bind:value={element.icon} {iconSet} />
@@ -1185,7 +1193,7 @@
</div> </div>
</ThemeSection> </ThemeSection>
{:else if element.type === 'repeater'} {: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"> <div class="space-y-1">
<Label for="element-repeater-source" class={label}>Tile</Label> <Label for="element-repeater-source" class={label}>Tile</Label>
<Select.Root <Select.Root
@@ -1307,7 +1315,7 @@
</p> </p>
</ThemeSection> </ThemeSection>
{:else if element.type === 'gauge'} {: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="grid grid-cols-2 gap-2">
<div class="space-y-1"> <div class="space-y-1">
<Label for="element-gauge-mode" class={label}>Style</Label> <Label for="element-gauge-mode" class={label}>Style</Label>
@@ -1423,7 +1431,7 @@
</div> </div>
</ThemeSection> </ThemeSection>
{:else if element.type === 'ticker'} {: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"> <div class="space-y-1">
<Label for="element-ticker-text" class={label}>Text</Label> <Label for="element-ticker-text" class={label}>Text</Label>
<Textarea <Textarea
@@ -1520,7 +1528,7 @@
{/if} {/if}
</ThemeSection> </ThemeSection>
{:else if element.type === 'button'} {: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"> <div class="space-y-1">
<Label for="element-button-label" class={label}>Label</Label> <Label for="element-button-label" class={label}>Label</Label>
<Input id="element-button-label" class={field} bind:value={element.label} /> <Input id="element-button-label" class={field} bind:value={element.label} />
@@ -1704,7 +1712,7 @@
</div> </div>
</ThemeSection> </ThemeSection>
{:else if element.type === 'path'} {: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"> <div class="space-y-1">
<Label for="element-path-data" class={label}>SVG path data</Label> <Label for="element-path-data" class={label}>SVG path data</Label>
<Textarea <Textarea
@@ -1850,7 +1858,7 @@
</div> </div>
</ThemeSection> </ThemeSection>
{:else if element.type === 'particles'} {: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"> <div class="space-y-1">
<Label for="element-particles-preset" class={label}>Preset</Label> <Label for="element-particles-preset" class={label}>Preset</Label>
<Select.Root <Select.Root
@@ -1954,7 +1962,7 @@
</p> </p>
</ThemeSection> </ThemeSection>
{:else if element.type === 'clock'} {: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"> <div class="space-y-1">
<Label for="element-clock-mode" class={label}>Style</Label> <Label for="element-clock-mode" class={label}>Style</Label>
<Select.Root <Select.Root
@@ -2121,7 +2129,7 @@
</p> </p>
</ThemeSection> </ThemeSection>
{:else if element.type === 'countdown'} {: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"> <div class="space-y-1.5">
<Label for="element-countdown-target" class={label}>Target date & time</Label> <Label for="element-countdown-target" class={label}>Target date & time</Label>
<DatePicker <DatePicker
@@ -2220,7 +2228,7 @@
</div> </div>
</ThemeSection> </ThemeSection>
{:else if element.type === 'widget'} {: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"> <div class="space-y-1.5">
<Label for="element-widget-type" class={label}>Widget Type</Label> <Label for="element-widget-type" class={label}>Widget Type</Label>
<Select.Root <Select.Root
@@ -2558,7 +2566,7 @@
</ThemeSection> </ThemeSection>
{/if} {/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"> <div class="space-y-1">
<Label for="element-mask-shape" class={label}>Clip shape</Label> <Label for="element-mask-shape" class={label}>Clip shape</Label>
<Select.Root <Select.Root
@@ -3,6 +3,7 @@
import Button from '$lib/components/ui/button/button.svelte'; import Button from '$lib/components/ui/button/button.svelte';
import Slider from '$lib/components/ui/slider/slider.svelte'; import Slider from '$lib/components/ui/slider/slider.svelte';
import type { ThemeV2Filters } from '$lib/themeV2'; import type { ThemeV2Filters } from '$lib/themeV2';
import { SlidersHorizontal } from '@lucide/svelte';
let { let {
filters, filters,
@@ -67,6 +68,7 @@
<ThemeSection <ThemeSection
title="Filters" title="Filters"
icon={SlidersHorizontal}
open={false} open={false}
actions={filterActions} actions={filterActions}
contentClass="space-y-2.5 pt-2.5" contentClass="space-y-2.5 pt-2.5"
@@ -17,7 +17,7 @@
type ThemeV2Group, type ThemeV2Group,
type ThemeV2MaskShape, type ThemeV2MaskShape,
} from '$lib/themeV2'; } from '$lib/themeV2';
import { Boxes, RefreshCw, Ungroup } from '@lucide/svelte'; import { Boxes, Layers, RefreshCw, Scan, Ungroup } from '@lucide/svelte';
let { let {
group = $bindable(), group = $bindable(),
@@ -58,7 +58,7 @@
</div> </div>
</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"> <div class="space-y-1">
<Label for="group-name" class={label}>Group name</Label> <Label for="group-name" class={label}>Group name</Label>
<Input <Input
@@ -96,6 +96,7 @@
<ThemeSection <ThemeSection
title="Group state" title="Group state"
icon={Layers}
contentClass="flex flex-wrap items-center gap-x-4 gap-y-2 pt-2.5" contentClass="flex flex-wrap items-center gap-x-4 gap-y-2 pt-2.5"
> >
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
@@ -167,7 +168,7 @@
ondelete={ondeleterule} 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"> <div class="space-y-1">
<Label for="group-mask-shape" class={label}>Clip shape</Label> <Label for="group-mask-shape" class={label}>Clip shape</Label>
<Select.Root <Select.Root
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import ThemeBehaviorInspector from '$lib/components/theme/ThemeBehaviorInspector.svelte'; import ThemeBehaviorInspector from '$lib/components/theme/ThemeBehaviorInspector.svelte';
import Button from '$lib/components/ui/button/button.svelte'; import Button from '$lib/components/ui/button/button.svelte';
import { themeEditorSectionOpen } from '$lib/themeEditorUiState';
import { themeBehaviorSummary, themeGroupInteractiveCount } from '$lib/themeLogic'; import { themeBehaviorSummary, themeGroupInteractiveCount } from '$lib/themeLogic';
import type { import type {
ThemeV2Behavior, ThemeV2Behavior,
@@ -30,8 +31,13 @@
ondelete: (id: string) => void; ondelete: (id: string) => void;
} = $props(); } = $props();
let open = $state(false); const sectionKey = 'Logic';
let open = $state(themeEditorSectionOpen.get(sectionKey) ?? false);
let selectedId = $state<string | undefined>(undefined); let selectedId = $state<string | undefined>(undefined);
$effect(() => {
themeEditorSectionOpen.set(sectionKey, open);
});
let section = $state<HTMLElement>(); let section = $state<HTMLElement>();
let lastFocusToken = 0; let lastFocusToken = 0;
+13 -5
View File
@@ -1,10 +1,12 @@
<script lang="ts"> <script lang="ts">
import { themeEditorSectionOpen } from '$lib/themeEditorUiState';
import { ChevronDown } from '@lucide/svelte'; import { ChevronDown } from '@lucide/svelte';
import { untrack } from 'svelte';
import type { Component, Snippet } from 'svelte'; import type { Component, Snippet } from 'svelte';
let { let {
title, title,
open = $bindable(true), open: initialOpen = true,
icon: Icon, icon: Icon,
badge, badge,
description, description,
@@ -27,6 +29,12 @@
headingClass?: string; headingClass?: string;
bordered?: boolean; bordered?: boolean;
} = $props(); } = $props();
let isOpen = $state(untrack(() => themeEditorSectionOpen.get(title) ?? initialOpen));
$effect(() => {
themeEditorSectionOpen.set(title, isOpen);
});
</script> </script>
<section class="{bordered ? 'border-t border-theme-800 pt-3 ' : ''}{className}"> <section class="{bordered ? 'border-t border-theme-800 pt-3 ' : ''}{className}">
@@ -34,8 +42,8 @@
<button <button
type="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" 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} aria-expanded={isOpen}
onclick={() => (open = !open)} onclick={() => (isOpen = !isOpen)}
> >
{#if Icon} {#if Icon}
<Icon class="size-3.5 shrink-0 text-primary" /> <Icon class="size-3.5 shrink-0 text-primary" />
@@ -49,7 +57,7 @@
</span> </span>
{/if} {/if}
<ChevronDown <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' ? 'rotate-180'
: ''}" : ''}"
/> />
@@ -61,7 +69,7 @@
{/if} {/if}
</div> </div>
{#if open} {#if isOpen}
<div class={contentClass}> <div class={contentClass}>
{#if description} {#if description}
<p class="text-[10px] leading-relaxed text-muted-foreground">{description}</p> <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 Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select'; import * as Select from '$lib/components/ui/select';
import type { ThemeV2TextDecoration, ThemeV2TextStyle, ThemeV2TextTransform } from '$lib/themeV2'; 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 { let {
element, element,
@@ -89,6 +89,7 @@
<ThemeSection <ThemeSection
title="Text style" title="Text style"
icon={Type}
open={false} open={false}
actions={textStyleActions} actions={textStyleActions}
contentClass="space-y-2 pt-2.5" contentClass="space-y-2 pt-2.5"
@@ -112,6 +112,7 @@
<ThemeSection <ThemeSection
title="Transform" title="Transform"
icon={RotateCw}
open={false} open={false}
actions={transformActions} actions={transformActions}
contentClass="space-y-2 pt-2.5" contentClass="space-y-2 pt-2.5"
@@ -3,7 +3,7 @@
import Button from '$lib/components/ui/button/button.svelte'; import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte'; import Input from '$lib/components/ui/input/input.svelte';
import type { ThemeV2ScriptValue } from '$lib/themeV2'; import type { ThemeV2ScriptValue } from '$lib/themeV2';
import { RotateCcw } from '@lucide/svelte'; import { Braces, RotateCcw } from '@lucide/svelte';
let { let {
variables, variables,
@@ -38,7 +38,12 @@
}; };
</script> </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"> <p class="text-[10px] leading-relaxed text-muted-foreground">
Starting values for Starting values for
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">{'{name}'}</code> <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: { button2: {
enabled: true, enabled: true,
label: 'View Profile', label: 'Join EZPPFarm',
url: '{profileUrl}', 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; if (ACTIVE_STATUSES.has(storeTheme.status)) return storeTheme;
const theme = installedMap.get(storeTheme.name); const theme = installedMap.get(storeTheme.name);
if (!theme) return storeTheme; if (!theme) return storeTheme;
const versionChanged = storeTheme.version !== theme.version;
// Replace the object rather than mutating it so the theme cards see the // 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. // new values when the list is rendered from a filtered derived array.
@@ -538,8 +539,8 @@ export const reloadThemes = async () => {
nsfw: theme.nsfw, nsfw: theme.nsfw,
workshopId: theme.workshopId, workshopId: theme.workshopId,
definition: theme.definition, definition: theme.definition,
updateAvailable: theme.updateAvailable, updateAvailable: versionChanged ? false : storeTheme.updateAvailable,
availableVersion: theme.availableVersion, availableVersion: versionChanged ? undefined : storeTheme.availableVersion,
cssStyle: theme.cssStyle, cssStyle: theme.cssStyle,
backups: theme.backups ?? storeTheme.backups, backups: theme.backups ?? storeTheme.backups,
}; };
+10 -2
View File
@@ -1,8 +1,6 @@
<script lang="ts"> <script lang="ts">
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import Logo from '$assets/logo.png'; 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 ThemeV2Background from '$lib/components/theme/ThemeV2Background.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog'; import * as AlertDialog from '$lib/components/ui/alert-dialog';
import AnimatedBg from '$lib/components/ui/animated-bg/AnimatedBg.svelte'; import AnimatedBg from '$lib/components/ui/animated-bg/AnimatedBg.svelte';
@@ -449,9 +447,19 @@
/> />
{#if editorWindow} {#if editorWindow}
{#await import('$lib/components/theme/ThemeEditorWindow.svelte')}
<div class="h-screen w-screen bg-theme-950"></div>
{:then editorModule}
{@const ThemeEditorWindow = editorModule.default}
<ThemeEditorWindow /> <ThemeEditorWindow />
{/await}
{:else if presenceEditorWindow} {:else if presenceEditorWindow}
{#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 /> <DiscordPresenceEditor standalone />
{/await}
{:else} {:else}
{#if editorOpen} {#if editorOpen}
<div <div
+223 -27
View File
@@ -3,15 +3,12 @@
import Logo from '$assets/logo.png'; import Logo from '$assets/logo.png';
import { ezppfarm } from '$lib/api/ezpp'; import { ezppfarm } from '$lib/api/ezpp';
import { import {
downloadAndInstallCommunityTheme,
fetchCommunityThemeDetails, fetchCommunityThemeDetails,
fetchCommunityThemes, fetchCommunityThemes,
type CommunityTheme, type CommunityTheme,
} from '$lib/communityThemes'; } from '$lib/communityThemes';
import DiagnosticsPanel from '$lib/components/diagnostics/DiagnosticsPanel.svelte';
import LogViewer from '$lib/components/logs/LogViewer.svelte';
import NotificationHub from '$lib/components/notifications/NotificationHub.svelte'; import NotificationHub from '$lib/components/notifications/NotificationHub.svelte';
import DiscordPresenceEditor from '$lib/components/presence/DiscordPresenceEditor.svelte';
import CommunityWorkshopView from '$lib/components/theme/CommunityWorkshopView.svelte';
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte'; import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
import ThemeReportDialog from '$lib/components/theme/ThemeReportDialog.svelte'; import ThemeReportDialog from '$lib/components/theme/ThemeReportDialog.svelte';
import ThemeV2Elements from '$lib/components/theme/ThemeV2Elements.svelte'; import ThemeV2Elements from '$lib/components/theme/ThemeV2Elements.svelte';
@@ -113,6 +110,7 @@
import { isThemeTrusted, themeCodeKind, trustTheme } from '$lib/themeTrust'; import { isThemeTrusted, themeCodeKind, trustTheme } from '$lib/themeTrust';
import { import {
normalizeChrome, normalizeChrome,
normalizeThemeVersion,
themeAssetUrl, themeAssetUrl,
themeV2ThumbnailStyle, themeV2ThumbnailStyle,
type ThemeV2ButtonAction, type ThemeV2ButtonAction,
@@ -179,9 +177,10 @@
import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrentWindow } from '@tauri-apps/api/window';
import { open } from '@tauri-apps/plugin-dialog'; import { open } from '@tauri-apps/plugin-dialog';
import { animate } from 'animejs'; import { animate } from 'animejs';
import semver from 'semver';
import { sileo } from 'sileo'; import { sileo } from 'sileo';
import { onMount } from 'svelte'; import { onMount, untrack } from 'svelte';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { fade, fly, scale } from 'svelte/transition'; import { fade, fly, scale } from 'svelte/transition';
import { TextMorph } from 'torph/svelte'; import { TextMorph } from 'torph/svelte';
@@ -321,6 +320,12 @@
let ownedThemesRequestId = 0; let ownedThemesRequestId = 0;
let logViewerOpen = $state(false); let logViewerOpen = $state(false);
let presenceEditorOpen = $state(false); let presenceEditorOpen = $state(false);
let checkingThemeUpdates = $state(false);
let themeUpdateCheckFailed = $state(false);
let updatingThemeFolder = $state<string | undefined>(undefined);
const communityThemeCache = new SvelteMap<string, CommunityTheme>();
let lastThemeUpdateCheck = 0;
const themeFilterOptions = [ const themeFilterOptions = [
{ id: 'all', label: 'All themes', icon: 'all-themes' }, { id: 'all', label: 'All themes', icon: 'all-themes' },
@@ -450,6 +455,7 @@
}); });
const themeButtonLabel = (theme: Theme, activeFolder: string | undefined) => { const themeButtonLabel = (theme: Theme, activeFolder: string | undefined) => {
if (updatingThemeFolder === theme.folder_name) return 'Updating Theme...';
if (activeFolder === theme.folder_name) return 'Theme in use'; if (activeFolder === theme.folder_name) return 'Theme in use';
if (theme.status === 'downloading') { if (theme.status === 'downloading') {
return `${theme.updateAvailable ? 'Updating' : 'Downloading'} Theme... (${Math.round(theme.progress * 100)}%)`; return `${theme.updateAvailable ? 'Updating' : 'Downloading'} Theme... (${Math.round(theme.progress * 100)}%)`;
@@ -537,34 +543,145 @@
} }
}; };
const updateInstalledTheme = async (theme: Theme) => { const checkInstalledThemeUpdates = async (force = false) => {
const wasActive = $active_custom_theme?.folder_name === theme.folder_name; if (checkingThemeUpdates) return;
const updateResult = await downloadTheme(theme, true); const now = Date.now();
if (!updateResult.success) { if (!force && now - lastThemeUpdateCheck < 60_000) return;
sileo.error({ lastThemeUpdateCheck = now;
title: 'Uhhm..',
description: updateResult.error || 'Failed to update theme.', const installed = get(custom_themes).filter(
}); (theme) =>
theme.status === 'installed' &&
theme.name !== 'Default' &&
typeof theme.workshopId === 'string' &&
theme.workshopId.length > 0
);
if (installed.length === 0) {
themeUpdateCheckFailed = false;
return; return;
} }
if (wasActive) { checkingThemeUpdates = true;
const updatedTheme = get(custom_themes).find((candidate) => candidate.name === theme.name); themeUpdateCheckFailed = false;
if (updatedTheme) { const remoteThemes = new SvelteMap<string, { theme: CommunityTheme; version: string }>();
let cursor = 0;
const checkNext = async () => {
while (cursor < installed.length) {
const theme = installed[cursor++];
const workshopId = theme.workshopId;
if (!workshopId) continue;
try {
const remote = await fetchCommunityThemeDetails(workshopId);
const remoteVersion = normalizeThemeVersion(remote.version);
if (!remoteVersion) throw new Error('The workshop returned an invalid theme version.');
remoteThemes.set(theme.folder_name, { theme: remote, version: remoteVersion });
communityThemeCache.set(theme.folder_name, remote);
} catch (error) {
console.warn(`[Themes] Could not check "${theme.name}" for updates:`, error);
themeUpdateCheckFailed = true;
communityThemeCache.delete(theme.folder_name);
}
}
};
try {
const workers = Math.min(4, installed.length);
await Promise.all(Array.from({ length: workers }, () => checkNext()));
custom_themes.update((themes) =>
themes.map((theme) => {
const remote = remoteThemes.get(theme.folder_name);
if (!remote) return theme;
const localVersion = normalizeThemeVersion(theme.version);
if (!localVersion) return theme;
const updateAvailable = semver.gt(remote.version, localVersion);
return {
...theme,
updateAvailable,
availableVersion: updateAvailable ? remote.version : undefined,
};
})
);
} finally {
checkingThemeUpdates = false;
}
};
const updateInstalledTheme = async (theme: Theme) => {
if (theme.status !== 'installed' || updatingThemeFolder) return;
const wasActive = $active_custom_theme?.folder_name === theme.folder_name;
updatingThemeFolder = theme.folder_name;
try {
if (theme.workshopId) {
const target =
communityThemeCache.get(theme.folder_name) ??
(await fetchCommunityThemeDetails(theme.workshopId));
const themeFolder = await path.join(await themesDirectory(), theme.folder_name);
let backup: string | undefined;
try {
backup = await invoke<string>('backup_theme_folder', { themeFolder });
} catch (error) {
console.warn('Could not back up the theme before updating it', error);
}
try {
await downloadAndInstallCommunityTheme(target, () => {}, {
folderName: theme.folder_name,
});
} catch (error) {
if (backup) {
await restoreThemeBackup(themeFolder, backup).catch((restoreError) =>
console.error('Could not restore the theme after a failed update', restoreError)
);
}
throw error;
}
} else {
const updateResult = await downloadTheme(theme, true);
if (!updateResult.success) {
throw new Error(updateResult.error || 'Failed to update theme.');
}
}
const updatedTheme = get(custom_themes).find(
(candidate) => candidate.folder_name === theme.folder_name
);
if (wasActive && updatedTheme) {
if (!isThemeTrusted(updatedTheme)) { if (!isThemeTrusted(updatedTheme)) {
requestThemeTrust(updatedTheme); requestThemeTrust(updatedTheme);
} else { } else {
await applyInstalledTheme(updatedTheme); await applyInstalledTheme(updatedTheme);
} }
} }
}
communityThemeCache.delete(theme.folder_name);
sileo.success({ sileo.success({
title: 'Yaay!', title: 'Yaay!',
description: 'Theme updated successfully!', description: 'Theme updated successfully!',
}); });
} catch (error) {
console.error(error);
sileo.error({
title: 'Could not update theme',
description: error instanceof Error ? error.message : String(error),
});
} finally {
updatingThemeFolder = undefined;
}
}; };
$effect(() => {
if (selectedView === 'THEMES') {
untrack(() => void checkInstalledThemeUpdates());
}
});
const openInstalledReport = async (theme: Theme) => { const openInstalledReport = async (theme: Theme) => {
if (!$currentUser) { if (!$currentUser) {
sileo.error({ sileo.error({
@@ -769,6 +886,7 @@
refreshingThemes = true; refreshingThemes = true;
try { try {
await reloadThemes(); await reloadThemes();
void checkInstalledThemeUpdates(true);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
sileo.error({ title: 'Could not refresh themes' }); sileo.error({ title: 'Could not refresh themes' });
@@ -3553,7 +3671,16 @@
</section> </section>
{:else if activeSettingsTab === 'diagnostics'} {:else if activeSettingsTab === 'diagnostics'}
<section> <section>
{#await import('$lib/components/diagnostics/DiagnosticsPanel.svelte')}
<div
class="flex min-h-[240px] items-center justify-center text-xs text-muted-foreground"
>
Loading diagnostics…
</div>
{:then diagnosticsModule}
{@const DiagnosticsPanel = diagnosticsModule.default}
<DiagnosticsPanel onRepair={openSetupWizard} /> <DiagnosticsPanel onRepair={openSetupWizard} />
{/await}
</section> </section>
{:else if activeSettingsTab === 'logging'} {:else if activeSettingsTab === 'logging'}
<section> <section>
@@ -3928,14 +4055,34 @@
{/if} {/if}
</header> </header>
<div class="min-h-0 flex-1 px-5 pt-4 pb-1"> <div class="relative min-h-0 flex-1 px-5 pt-4 pb-1">
{#if activeThemesTab === 'workshop'} {#if activeThemesTab === 'workshop'}
<div
class="absolute inset-0"
in:fly={{ duration: $reduceAnimations ? 0 : 240, y: 8, opacity: 0 }}
out:fade={{ duration: $reduceAnimations ? 0 : 160 }}
>
{#await import('$lib/components/theme/CommunityWorkshopView.svelte')}
<div
class="flex h-full min-h-[320px] items-center justify-center text-xs text-muted-foreground"
>
Loading workshop…
</div>
{:then workshopModule}
{@const CommunityWorkshopView = workshopModule.default}
<CommunityWorkshopView <CommunityWorkshopView
onApplyTheme={applyInstalledTheme} onApplyTheme={applyInstalledTheme}
onRequestTrust={(theme) => requestThemeTrust(theme, false)} onRequestTrust={(theme) => requestThemeTrust(theme, false)}
onEditTheme={openThemeEditor} onEditTheme={openThemeEditor}
/> />
{/await}
</div>
{:else} {:else}
<div
class="absolute inset-0"
in:fly={{ duration: $reduceAnimations ? 0 : 240, y: 8, opacity: 0 }}
out:fade={{ duration: $reduceAnimations ? 0 : 160 }}
>
<ScrollContainer class="pr-2"> <ScrollContainer class="pr-2">
{#if filteredThemes.length > 0} {#if filteredThemes.length > 0}
{#key themeFilter} {#key themeFilter}
@@ -3971,12 +4118,9 @@
: 'border-theme-800/60'} {$reduceAnimations : 'border-theme-800/60'} {$reduceAnimations
? '' ? ''
: 'transition-all duration-300 hover:-translate-y-0.5 hover:border-white/20 hover:bg-theme-900/50 hover:shadow-xl hover:shadow-black/25'}" : 'transition-all duration-300 hover:-translate-y-0.5 hover:border-white/20 hover:bg-theme-900/50 hover:shadow-xl hover:shadow-black/25'}"
in:fly={{ class:installed-theme-card-enter={!$reduceAnimations}
duration: $reduceAnimations ? 0 : 260, style="animation-delay: {Math.min(index * 24, 144)}ms"
delay: $reduceAnimations ? 0 : Math.min(index * 35, 210), out:fade={{ duration: $reduceAnimations ? 0 : 160 }}
y: 8,
opacity: 0,
}}
> >
<div <div
class="relative aspect-video w-full shrink-0 overflow-hidden bg-theme-950/60" class="relative aspect-video w-full shrink-0 overflow-hidden bg-theme-950/60"
@@ -4121,7 +4265,9 @@
class="mt-auto flex items-center justify-between gap-2 border-t border-theme-800/40 pt-2.5" class="mt-auto flex items-center justify-between gap-2 border-t border-theme-800/40 pt-2.5"
> >
<span class="min-w-0 truncate text-[9px] text-muted-foreground"> <span class="min-w-0 truncate text-[9px] text-muted-foreground">
{#if theme.updateAvailable && theme.availableVersion} {#if updatingThemeFolder === theme.folder_name}
Updating…
{:else if theme.updateAvailable && theme.availableVersion}
<span class="text-primary" <span class="text-primary"
>v{theme.availableVersion} available</span >v{theme.availableVersion} available</span
> >
@@ -4141,6 +4287,7 @@
aria-busy={theme.status === 'downloading' || aria-busy={theme.status === 'downloading' ||
theme.status === 'extracting'} theme.status === 'extracting'}
disabled={isActive || disabled={isActive ||
updatingThemeFolder === theme.folder_name ||
theme.status === 'downloading' || theme.status === 'downloading' ||
theme.status === 'extracting' || theme.status === 'extracting' ||
theme.status === 'deleting'} theme.status === 'deleting'}
@@ -4204,7 +4351,11 @@
class="gap-2 text-[11px]" class="gap-2 text-[11px]"
onclick={() => openThemeEditor(theme)} onclick={() => openThemeEditor(theme)}
> >
<ThemeIcon name="edit" set={iconSet} class="size-3.5" /> <ThemeIcon
name="edit"
set={iconSet}
class="size-3.5"
/>
Edit theme Edit theme
</DropdownMenu.Item> </DropdownMenu.Item>
{/if} {/if}
@@ -4225,6 +4376,7 @@
{#if canUpdateTheme} {#if canUpdateTheme}
<DropdownMenu.Item <DropdownMenu.Item
class="gap-2 text-[11px]" class="gap-2 text-[11px]"
disabled={updatingThemeFolder === theme.folder_name}
onclick={() => updateInstalledTheme(theme)} onclick={() => updateInstalledTheme(theme)}
> >
<ThemeIcon <ThemeIcon
@@ -4289,6 +4441,10 @@
<p class="text-sm font-semibold text-theme-100"> <p class="text-sm font-semibold text-theme-100">
{#if themeSearch} {#if themeSearch}
No themes match "{themeSearch}" No themes match "{themeSearch}"
{:else if themeFilter === 'updates' && checkingThemeUpdates}
Checking for updates…
{:else if themeFilter === 'updates' && themeUpdateCheckFailed}
Could not check for updates
{:else if themeFilter === 'updates'} {:else if themeFilter === 'updates'}
Everything is up to date Everything is up to date
{:else} {:else}
@@ -4298,6 +4454,10 @@
<p class="max-w-xs text-xs leading-relaxed"> <p class="max-w-xs text-xs leading-relaxed">
{#if themeSearch} {#if themeSearch}
Try a different name, author or API version. Try a different name, author or API version.
{:else if themeFilter === 'updates' && checkingThemeUpdates}
Looking for newer workshop releases of your installed themes.
{:else if themeFilter === 'updates' && themeUpdateCheckFailed}
Some themes could not be checked. Refresh the list to try again.
{:else if themeFilter === 'updates'} {:else if themeFilter === 'updates'}
You are running the latest version of every installed theme. You are running the latest version of every installed theme.
{:else} {:else}
@@ -4320,6 +4480,7 @@
</div> </div>
{/if} {/if}
</ScrollContainer> </ScrollContainer>
</div>
{/if} {/if}
</div> </div>
</div> </div>
@@ -4382,10 +4543,30 @@
{/if} {/if}
{#if logViewerOpen} {#if logViewerOpen}
{#await import('$lib/components/logs/LogViewer.svelte')}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 text-xs text-muted-foreground backdrop-blur-sm"
>
Opening logs…
</div>
{:then logsModule}
{@const LogViewer = logsModule.default}
<LogViewer onclose={() => (logViewerOpen = false)} /> <LogViewer onclose={() => (logViewerOpen = false)} />
{/await}
{/if} {/if}
{#if presenceEditorOpen}
{#await import('$lib/components/presence/DiscordPresenceEditor.svelte')}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 text-xs text-muted-foreground backdrop-blur-sm"
>
Opening editor…
</div>
{:then presenceModule}
{@const DiscordPresenceEditor = presenceModule.default}
<DiscordPresenceEditor bind:open={presenceEditorOpen} /> <DiscordPresenceEditor bind:open={presenceEditorOpen} />
{/await}
{/if}
</div> </div>
<style> <style>
@@ -4402,6 +4583,21 @@
animation: theme-download-shimmer 1.8s ease-in-out infinite; animation: theme-download-shimmer 1.8s ease-in-out infinite;
} }
.installed-theme-card-enter {
animation: installed-theme-card-in 240ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
@keyframes installed-theme-card-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.theme-download-shimmer { .theme-download-shimmer {
animation: none; animation: none;