feat: add discord rich presence editor window and interactive embed preview

This commit is contained in:
HorizonCode committed 2026-10-31 17:59:07 +01:00
1 parent e3abea8d83
commit 386c829455
30 files changed
+4102 -263

No files matched your search

@@ -345,7 +345,7 @@
<Button
variant="outline"
size="sm"
class="border-theme-800 bg-theme-950"
class="border-theme-800 bg-theme-950 hover:border-primary hover:bg-primary hover:text-primary-foreground transition-colors"
onclick={copyReport}
>
<ClipboardCopy class="size-4" />
@@ -354,7 +354,7 @@
<Button
variant="outline"
size="sm"
class="border-theme-800 bg-theme-950"
class="border-theme-800 bg-theme-950 hover:border-primary hover:bg-primary hover:text-primary-foreground transition-colors"
onclick={runChecks}
disabled={checking}
>
File diff suppressed because it is too large. Load diff
@@ -19,6 +19,8 @@
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 { config } from '$lib/config';
import { currentUserInfo } from '$lib/data';
import {
defaultFontsourceOptions,
downloadFontsourceFont,
@@ -28,6 +30,15 @@
themeFontKey,
type FontsourceFont,
} from '$lib/fontsource';
import {
launcherVersion,
onlineFriends,
osuBuild,
osuStream,
serverPing,
setupValues,
} from '$lib/global';
import { getLauncherVersion, getReleaseStream, getVersion } from '$lib/osuUtil';
import {
THEME_CLOCK_VARIABLE_NAMES,
isThemeClockVariable,
@@ -80,6 +91,9 @@
themesDirectory,
toSafeName,
} from '$lib/themes';
import type { EZPPUser, EZPPUserInfo } from '$lib/types';
import { currentUser } from '$lib/userAuthentication';
import { osuInstallationPath } from '$lib/userSettings';
import { formatBytes } from '$lib/utils';
import {
AlertTriangle,
@@ -1766,12 +1780,37 @@
}
}
type ThemeEditorGlobalSyncPayload = {
user?: EZPPUser | null;
info?: EZPPUserInfo | null;
friends?: number;
osuBuild?: string;
osuStream?: string;
serverPing?: number;
launcherVersion?: string;
};
onMount(() => {
const observer = new ResizeObserver(updateCanvasScale);
if (canvasHost) observer.observe(canvasHost);
let unlisten: UnlistenFn | undefined;
let unlistenClose: UnlistenFn | undefined;
let unlistenGlobalSync: UnlistenFn | undefined;
listen('theme-editor-context', () => loadContext()).then((stop) => (unlisten = stop));
listen<ThemeEditorGlobalSyncPayload>('sync-theme-editor-global-data', (event) => {
const data = event.payload;
if (!data) return;
if (data.user !== undefined) currentUser.set(data.user ?? undefined);
if (data.info !== undefined) currentUserInfo.set(data.info ?? undefined);
if (data.friends !== undefined) onlineFriends.set(data.friends);
if (data.osuBuild) osuBuild.set(data.osuBuild);
if (data.osuStream) osuStream.set(data.osuStream);
if (data.serverPing !== undefined) serverPing.set(data.serverPing);
if (data.launcherVersion) launcherVersion.set(data.launcherVersion);
}).then((stop) => (unlistenGlobalSync = stop));
emit('request-theme-editor-global-data').catch(() => {});
getCurrentWindow()
.onCloseRequested((event) => {
if (allowClose || !dirty) return;
@@ -1780,11 +1819,37 @@
})
.then((stop) => (unlistenClose = stop));
loadContext();
getLauncherVersion()
.then((ver) => {
if (ver) launcherVersion.set(ver);
})
.catch(() => {});
const teardownValues = setupValues();
config
.value<string>('osu_installation_path')
.get('')
.then(async (path) => {
if (!path) return;
osuInstallationPath.set(path);
try {
const [stream, version] = await Promise.all([
getReleaseStream(path).catch(() => undefined),
getVersion(path).catch(() => undefined),
]);
if (stream) osuStream.set(stream);
if (version) osuBuild.set(version);
} catch {}
})
.catch(() => {});
return () => {
observer.disconnect();
unlisten?.();
unlistenClose?.();
unlistenGlobalSync?.();
teardownValues?.();
stopHistoryTimer();
};
});
@@ -2928,7 +2993,7 @@
return;
}
allowClose = true;
await getCurrentWindow().close();
await invoke('close_theme_editor');
}
async function saveAndClose() {
@@ -2937,14 +3002,14 @@
clearDraft();
allowClose = true;
closeDialogOpen = false;
await getCurrentWindow().close();
await invoke('close_theme_editor');
}
async function discardAndClose() {
clearDraft();
allowClose = true;
closeDialogOpen = false;
await getCurrentWindow().close();
await invoke('close_theme_editor');
}
</script>
@@ -5379,6 +5444,10 @@
<div class="min-h-0 flex-1 overflow-y-auto">
<ThemeElementInspector
bind:element={definition.elements[selectedIndex]}
iconSet={definition.iconSet}
themeName={definition.name}
themeAuthor={definition.author}
themeVersion={definition.version}
{fontOptions}
{elements}
{behaviors}
@@ -2,6 +2,7 @@
import ThemeComponentEditor from '$lib/components/theme/ThemeComponentEditor.svelte';
import ThemeCssClasses from '$lib/components/theme/ThemeCssClasses.svelte';
import ThemeFilterControls from '$lib/components/theme/ThemeFilterControls.svelte';
import ThemeIconPicker from '$lib/components/theme/ThemeIconPicker.svelte';
import ThemeLinkedLogic from '$lib/components/theme/ThemeLinkedLogic.svelte';
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
import ThemeTextStyleControls from '$lib/components/theme/ThemeTextStyleControls.svelte';
@@ -14,11 +15,13 @@
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 { getThemeDataSampleValue, resolveThemeDataValue, themeGlobalData } from '$lib/themeData';
import type { ThemeFontChoice } from '$lib/themeFonts';
import { THEME_ICON_NAMES } from '$lib/themeIcons';
import { DEFAULT_THEME_ICON_SET, type ThemeIconSet } from '$lib/themeIcons';
import {
THEME_DATA_FIELDS,
THEME_PARTICLE_PRESETS,
themeGroupAncestors,
type ThemeV2Behavior,
type ThemeV2BehaviorTarget,
type ThemeV2Element,
@@ -48,6 +51,10 @@
elements,
behaviors,
groups,
iconSet = DEFAULT_THEME_ICON_SET,
themeName = 'My Theme',
themeAuthor = '',
themeVersion = '1.0.0',
variables = [],
focusToken = 0,
onreplace,
@@ -61,6 +68,10 @@
elements: ThemeV2Element[];
behaviors: ThemeV2Behavior[];
groups: ThemeV2Group[];
iconSet?: ThemeIconSet;
themeName?: string;
themeAuthor?: string;
themeVersion?: string;
variables?: string[];
focusToken?: number;
onreplace: (kind: 'image' | 'video' | 'lottie') => void;
@@ -92,6 +103,70 @@
node.setSelectionRange(caret, caret);
});
}
const currentFieldLabel = $derived(
element.type === 'data'
? (THEME_DATA_FIELDS.find((f) => f.value === element.field)?.label ?? element.field)
: ''
);
const currentRawValue = $derived(
element.type === 'data'
? resolveThemeDataValue(element.field, {
global: $themeGlobalData,
theme: {
name: themeName || 'Theme',
author: themeAuthor || '',
version: themeVersion || '1.0.0',
},
screen: 'home',
mode: element.mode,
})
: ''
);
const currentSampleValue = $derived(
element.type === 'data' ? getThemeDataSampleValue(element.field, element.mode) : ''
);
const currentEffectiveValue = $derived(
element.type === 'data'
? currentRawValue && currentRawValue.trim() !== ''
? currentRawValue
: (element.defaultValue ?? '')
: ''
);
const previewDisplayValue = $derived(
element.type === 'data'
? currentEffectiveValue && currentEffectiveValue.trim() !== ''
? currentEffectiveValue
: currentSampleValue
: ''
);
const previewResult = $derived.by(() => {
if (element.type !== 'data') return '';
const template = element.text?.trim() ? element.text : '{value}';
const fallback = element.defaultValue ?? '';
return template
.replaceAll('{value}', previewDisplayValue)
.replaceAll('{defaultValue}', fallback)
.replaceAll('{fallback}', fallback);
});
const parentGroupAncestors = $derived(
element.groupId && groups ? themeGroupAncestors(groups, element) : []
);
const parentGroupMorphs = $derived(parentGroupAncestors.some((group) => group.morph === true));
const parentGroupMorphDuration = $derived.by(() => {
for (const group of parentGroupAncestors) {
if (group.morphDuration && group.morphDuration > 0) return group.morphDuration;
}
return undefined;
});
</script>
<div class="space-y-3 p-3">
@@ -237,6 +312,63 @@
Interactive
</Label>
</div>
<div
class="flex items-center gap-1.5"
title="Smoothly morph text and number changes using Torph."
>
<Checkbox
id="element-morph"
checked={element.morph === true || (element.morph !== false && parentGroupMorphs)}
onCheckedChange={(checked) => {
if (parentGroupMorphs) {
element.morph = checked === true ? true : false;
} else {
element.morph = checked === true;
}
}}
/>
<Label for="element-morph" class="text-[10px] font-normal text-muted-foreground">
Morph text (Torph)
</Label>
{#if parentGroupMorphs && element.morph !== false}
<span class="text-[9px] text-muted-foreground" title="Inherited from parent group"
>(group)</span
>
{/if}
{#if parentGroupMorphs && element.morph !== undefined}
<button
type="button"
class="text-[9px] text-primary hover:underline ml-0.5"
onclick={() => delete element.morph}
title="Reset to inherit from group"
>
reset
</button>
{/if}
</div>
{#if element.morph === true || (element.morph !== false && parentGroupMorphs)}
<div class="w-full space-y-1 pt-1">
<div class="flex items-center justify-between">
<Label for="element-morph-duration" class={label}>Morph duration</Label>
<span class="text-[10px] text-muted-foreground">
{element.morphDuration ?? (parentGroupMorphDuration || 400)}ms
</span>
</div>
<Input
id="element-morph-duration"
class={field}
type="number"
min="50"
max="3000"
step="50"
value={element.morphDuration ?? (parentGroupMorphDuration || 400)}
oninput={(event) => {
const val = Number((event.currentTarget as HTMLInputElement).value);
element.morphDuration = Number.isFinite(val) && val > 0 ? val : 400;
}}
/>
</div>
{/if}
</ThemeSection>
<ThemeCssClasses
@@ -259,7 +391,7 @@
{#if element.type === 'text' || element.type === 'data'}
<ThemeSection title={element.type === 'data' ? 'Data' : 'Text'} contentClass="space-y-3 pt-2.5">
{#if element.type === 'data'}
<div class="space-y-1">
<div class="space-y-1.5">
<Label for="element-data-field" class={label}>Value</Label>
<Select.Root
type="single"
@@ -272,8 +404,7 @@
id="element-data-field"
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{THEME_DATA_FIELDS.find((field) => field.value === element.field)?.label ??
element.field}
{currentFieldLabel}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
{#each THEME_DATA_FIELDS as field (field.value)}
@@ -281,6 +412,48 @@
{/each}
</Select.Content>
</Select.Root>
<!-- Current Evaluated Value Display -->
<div class="rounded-md border border-theme-800/80 bg-theme-950/80 p-2 space-y-1">
<div class="flex items-center justify-between text-[10px] text-muted-foreground">
<span class="font-medium">Selected Value ({currentFieldLabel})</span>
{#if currentRawValue && currentRawValue.trim() !== ''}
<span
class="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-medium text-emerald-400 border border-emerald-500/25"
>
Live value
</span>
{:else if element.defaultValue && element.defaultValue.trim() !== ''}
<span
class="rounded bg-amber-500/15 px-1.5 py-0.5 text-[9px] font-medium text-amber-400 border border-amber-500/25"
>
Default
</span>
{:else if currentSampleValue}
<span
class="rounded bg-sky-500/15 px-1.5 py-0.5 text-[9px] font-medium text-sky-400 border border-sky-500/25"
>
Sample preview
</span>
{:else}
<span
class="rounded bg-theme-800 px-1.5 py-0.5 text-[9px] font-medium text-muted-foreground"
>
Empty
</span>
{/if}
</div>
<div class="flex items-center justify-between gap-2">
<span class="truncate font-mono text-xs font-semibold text-theme-50">
{previewDisplayValue ? previewDisplayValue : '(empty)'}
</span>
{#if !currentRawValue && (!element.defaultValue || element.defaultValue.trim() === '') && currentSampleValue}
<span class="text-[9px] text-muted-foreground shrink-0"> (sample preview) </span>
{:else if !currentRawValue && element.defaultValue}
<span class="text-[9px] text-muted-foreground shrink-0"> (using default) </span>
{/if}
</div>
</div>
</div>
{#if element.field.startsWith('user.') && element.field !== 'user.name' && element.field !== 'user.country'}
<div class="space-y-1">
@@ -322,6 +495,26 @@
still work too.
</p>
</div>
<div class="space-y-1">
<Label for="element-data-default" class={label}>Default value</Label>
<Input
id="element-data-default"
class={field}
bind:value={element.defaultValue}
placeholder={element.field === 'user.name' ? 'e.g. Guest' : 'e.g. -'}
/>
<p class="text-[9px] leading-relaxed text-muted-foreground">
Fallback value used when the data is empty or unavailable (such as when logged out).
</p>
</div>
<div class="rounded-md border border-theme-800/60 bg-theme-900/40 p-2 space-y-0.5">
<div class="text-[9px] uppercase tracking-wide text-muted-foreground font-semibold">
Result Preview
</div>
<div class="truncate font-mono text-xs font-semibold text-primary">
{previewResult || '(empty)'}
</div>
</div>
{:else}
<div class="space-y-1">
<Label for="element-text" class={label}>Text</Label>
@@ -950,27 +1143,9 @@
<ThemeSection title="Icon" contentClass="space-y-3 pt-2.5">
<div class="space-y-1">
<Label for="element-icon" class={label}>Icon</Label>
<Select.Root
type="single"
value={element.icon}
onValueChange={(value) => {
if (value) element.icon = value as typeof element.icon;
}}
>
<Select.Trigger
id="element-icon"
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{element.icon}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
{#each THEME_ICON_NAMES as name (name)}
<Select.Item value={name} label={name} />
{/each}
</Select.Content>
</Select.Root>
<ThemeIconPicker bind:value={element.icon} {iconSet} />
<p class="text-[9px] leading-relaxed text-muted-foreground">
Icons follow the theme's icon set, so the same layer switches families with the theme.
Choose from over 17,000 icons. Launcher icons adapt to the theme's icon family.
</p>
</div>
<div class="grid grid-cols-2 gap-2">
@@ -1033,25 +1208,7 @@
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label for="element-repeater-icon" class={label}>Icon</Label>
<Select.Root
type="single"
value={element.icon ?? 'music'}
onValueChange={(value) => {
if (value) element.icon = value as typeof element.icon;
}}
>
<Select.Trigger
id="element-repeater-icon"
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{element.icon ?? 'music'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
{#each THEME_ICON_NAMES as name (name)}
<Select.Item value={name} label={name} />
{/each}
</Select.Content>
</Select.Root>
<ThemeIconPicker bind:value={element.icon} {iconSet} />
</div>
<div class="space-y-1">
<Label for="element-repeater-color" class={label}>Color</Label>
@@ -1439,26 +1596,7 @@
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label for="element-button-icon" class={label}>Icon</Label>
<Select.Root
type="single"
value={element.icon ?? 'none'}
onValueChange={(value) => {
element.icon = value && value !== 'none' ? (value as typeof element.icon) : undefined;
}}
>
<Select.Trigger
id="element-button-icon"
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{element.icon ?? 'None'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
<Select.Item value="none" label="None" />
{#each THEME_ICON_NAMES as name (name)}
<Select.Item value={name} label={name} />
{/each}
</Select.Content>
</Select.Root>
<ThemeIconPicker bind:value={element.icon} {iconSet} allowNone />
</div>
<div class="space-y-1">
<Label for="element-button-radius" class={label}>Radius</Label>
@@ -114,6 +114,40 @@
/>
<Label for="group-locked" class="text-[10px] font-normal text-muted-foreground">Locked</Label>
</div>
<div
class="flex items-center gap-1.5"
title="Smoothly morph text and number changes across layers in this group using Torph."
>
<Checkbox
id="group-morph"
checked={group.morph === true}
onCheckedChange={(checked) => (group.morph = checked === true)}
/>
<Label for="group-morph" class="text-[10px] font-normal text-muted-foreground">
Morph text (Torph)
</Label>
</div>
{#if group.morph}
<div class="w-full space-y-1 pt-1">
<div class="flex items-center justify-between">
<Label for="group-morph-duration" class={label}>Morph duration</Label>
<span class="text-[10px] text-muted-foreground">{group.morphDuration ?? 400}ms</span>
</div>
<Input
id="group-morph-duration"
class={field}
type="number"
min="50"
max="3000"
step="50"
value={group.morphDuration ?? 400}
oninput={(event) => {
const val = Number((event.currentTarget as HTMLInputElement).value);
group.morphDuration = Number.isFinite(val) && val > 0 ? val : 400;
}}
/>
</div>
{/if}
</ThemeSection>
<ThemeCssClasses
+2 -4
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { THEME_ICON_BODIES } from '$lib/themeIconData';
import { getThemeIconBody } from '$lib/themeIconCatalog';
import { DEFAULT_THEME_ICON_SET, type ThemeIconName, type ThemeIconSet } from '$lib/themeIcons';
let {
@@ -14,9 +14,7 @@
class?: string;
} = $props();
const icon = $derived(
THEME_ICON_BODIES[set]?.[name] ?? THEME_ICON_BODIES[DEFAULT_THEME_ICON_SET][name]
);
const icon = $derived(getThemeIconBody(name, set));
</script>
<svg
@@ -0,0 +1,393 @@
<script lang="ts">
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import * as Popover from '$lib/components/ui/popover';
import {
getFullIconCatalog,
type IconPackKey,
type ThemeIconCatalogItem,
} from '$lib/themeIconCatalog';
import { DEFAULT_THEME_ICON_SET, type ThemeIconSet } from '$lib/themeIcons';
import ThemeIcon from './ThemeIcon.svelte';
import { Check, ChevronDown, ChevronsUpDown, LayoutGrid, List, Search, X } from '@lucide/svelte';
let {
value = $bindable(undefined),
iconSet = DEFAULT_THEME_ICON_SET,
placeholder = 'Select icon...',
disabled = false,
allowNone = false,
onchange,
}: {
value?: string;
iconSet?: ThemeIconSet;
placeholder?: string;
disabled?: boolean;
allowNone?: boolean;
onchange?: (val: string | undefined) => void;
} = $props();
let open = $state(false);
let searchQuery = $state('');
let selectedPack = $state<IconPackKey | 'all'>('all');
let viewMode = $state<'grid' | 'list'>('grid');
let scrollContainer = $state<HTMLDivElement | null>(null);
let scrollTop = $state(0);
const containerHeight = 280;
const gridColumns = 5;
const gridRowHeight = 54;
const listRowHeight = 34;
const overscan = 4;
const rowHeight = $derived(viewMode === 'grid' ? gridRowHeight : listRowHeight);
const catalog = getFullIconCatalog();
const packTabs: { key: IconPackKey | 'all'; label: string; count: number }[] = [
{ key: 'all', label: 'All', count: catalog.length },
{ key: 'launcher', label: 'Launcher', count: 47 },
{ key: 'lucide', label: 'Lucide', count: 2078 },
{ key: 'phosphor', label: 'Phosphor', count: 9161 },
{ key: 'tabler', label: 'Tabler', count: 6305 },
];
const filteredItems = $derived.by(() => {
let items = catalog;
if (selectedPack !== 'all') {
items = items.filter((it) => it.pack === selectedPack);
}
const q = searchQuery.trim().toLowerCase();
if (q) {
items = items.filter(
(it) => it.name.toLowerCase().includes(q) || it.id.toLowerCase().includes(q)
);
}
return items;
});
const numRows = $derived(
viewMode === 'grid' ? Math.ceil(filteredItems.length / gridColumns) : filteredItems.length
);
const totalHeight = $derived(numRows * rowHeight);
const startIndex = $derived(Math.max(0, Math.floor(scrollTop / rowHeight) - overscan));
const endIndex = $derived(
Math.min(numRows, Math.ceil((scrollTop + containerHeight) / rowHeight) + overscan)
);
type VirtualRow = {
index: number;
items?: ThemeIconCatalogItem[];
item?: ThemeIconCatalogItem;
};
const visibleRows = $derived.by(() => {
const rows: VirtualRow[] = [];
if (viewMode === 'grid') {
for (let i = startIndex; i < endIndex; i++) {
const rowItems = filteredItems.slice(i * gridColumns, (i + 1) * gridColumns);
rows.push({ index: i, items: rowItems });
}
} else {
for (let i = startIndex; i < endIndex; i++) {
const item = filteredItems[i];
if (item) {
rows.push({ index: i, item });
}
}
}
return rows;
});
$effect(() => {
// When search, pack, or view mode changes, reset scroll to top
void searchQuery;
void selectedPack;
void viewMode;
if (scrollContainer) {
scrollContainer.scrollTop = 0;
scrollTop = 0;
}
});
function selectIcon(id: string | undefined) {
if (id === 'none' || !id) {
value = undefined;
onchange?.(undefined);
} else {
value = id;
onchange?.(id);
}
open = false;
}
function displayName(val: string | undefined): string {
if (!val || val === 'none') return 'None';
if (val.includes(':')) {
return val.slice(val.indexOf(':') + 1);
}
return val;
}
function displayPack(val: string | undefined): string | null {
if (!val || val === 'none') return null;
if (val.startsWith('lucide:')) return 'Lucide';
if (val.startsWith('ph:')) return 'Phosphor';
if (val.startsWith('tabler:')) return 'Tabler';
return 'Launcher';
}
</script>
<Popover.Root bind:open>
<Popover.Trigger
{disabled}
class="flex h-8 w-full items-center justify-between gap-2 rounded-md border border-theme-800 bg-theme-950 px-2.5 text-xs text-theme-50 transition-colors hover:bg-theme-900/60 focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
>
{#if !value || value === 'none'}
<span class="flex items-center gap-2 text-muted-foreground truncate">
<span
class="flex size-4 items-center justify-center rounded border border-dashed border-theme-700 text-[10px]"
>✕</span
>
<span>{allowNone ? 'None' : placeholder}</span>
</span>
{:else}
<span class="flex items-center gap-2 min-w-0 truncate">
<span
class="flex size-5 shrink-0 items-center justify-center rounded bg-theme-900 border border-theme-800 text-theme-200"
>
<ThemeIcon name={value} set={iconSet} size={15} />
</span>
<span class="truncate font-mono text-xs text-theme-100">{displayName(value)}</span>
{#if displayPack(value)}
<span
class="rounded bg-theme-800/80 px-1 py-0.5 text-[8px] uppercase tracking-wide text-muted-foreground shrink-0"
>
{displayPack(value)}
</span>
{/if}
</span>
{/if}
<ChevronsUpDown class="size-3.5 shrink-0 text-muted-foreground opacity-60" />
</Popover.Trigger>
<Popover.Content
align="start"
side="bottom"
sideOffset={4}
class="w-[360px] max-w-[95vw] rounded-lg border border-theme-800 bg-theme-950 p-0 text-theme-50 shadow-2xl"
>
<!-- Search Bar -->
<div class="border-b border-theme-800/80 p-2">
<div class="relative flex items-center">
<Search class="absolute left-2.5 size-3.5 text-muted-foreground pointer-events-none" />
<Input
bind:value={searchQuery}
placeholder="Search 17,000+ icons..."
class="h-8 pl-8 pr-7 text-xs border-theme-800 bg-theme-900/60 placeholder:text-muted-foreground"
autofocus
/>
{#if searchQuery}
<button
type="button"
class="absolute right-2 text-muted-foreground hover:text-theme-100 transition-colors"
onclick={() => (searchQuery = '')}
>
<X class="size-3.5" />
</button>
{/if}
</div>
</div>
<!-- Pack Filter Tabs & View Toggle -->
<div
class="flex items-center justify-between gap-1 border-b border-theme-800/60 bg-theme-900/30 px-2 py-1.5"
>
<div class="flex items-center gap-1 overflow-x-auto no-scrollbar py-0.5">
{#each packTabs as tab (tab.key)}
<button
type="button"
class="shrink-0 rounded px-2 py-0.5 text-[10px] font-medium transition-colors {selectedPack ===
tab.key
? 'bg-primary/20 text-primary border border-primary/40 font-semibold'
: 'text-muted-foreground hover:bg-theme-800/60 hover:text-theme-200 border border-transparent'}"
onclick={() => (selectedPack = tab.key)}
>
{tab.label}
</button>
{/each}
</div>
<div class="flex items-center gap-0.5 shrink-0 pl-1 border-l border-theme-800/60">
<button
type="button"
class="rounded p-1 text-muted-foreground hover:text-theme-100 transition-colors {viewMode ===
'grid'
? 'bg-theme-800 text-theme-100'
: ''}"
title="Grid view"
onclick={() => (viewMode = 'grid')}
>
<LayoutGrid class="size-3.5" />
</button>
<button
type="button"
class="rounded p-1 text-muted-foreground hover:text-theme-100 transition-colors {viewMode ===
'list'
? 'bg-theme-800 text-theme-100'
: ''}"
title="List view"
onclick={() => (viewMode = 'list')}
>
<List class="size-3.5" />
</button>
</div>
</div>
<!-- Allow "None" option if requested -->
{#if allowNone}
<div class="border-b border-theme-800/50 px-2 py-1 bg-theme-900/20">
<button
type="button"
class="flex w-full items-center justify-between rounded px-2 py-1 text-xs transition-colors {value ===
'none' || !value
? 'bg-primary/20 text-primary font-medium'
: 'text-muted-foreground hover:bg-theme-800/60 hover:text-theme-100'}"
onclick={() => selectIcon('none')}
>
<span class="flex items-center gap-2">
<span
class="flex size-4 items-center justify-center rounded border border-dashed border-theme-700 text-[10px]"
>✕</span
>
<span>None (No icon)</span>
</span>
{#if value === 'none' || !value}
<Check class="size-3 text-primary" />
{/if}
</button>
</div>
{/if}
<!-- Virtual Scrolling List/Grid -->
<div
bind:this={scrollContainer}
class="overflow-y-auto px-2 py-1 select-none"
style="height: {containerHeight}px;"
onscroll={(e) => {
scrollTop = (e.currentTarget as HTMLElement).scrollTop;
}}
>
{#if filteredItems.length === 0}
<div
class="flex flex-col items-center justify-center py-12 text-center text-xs text-muted-foreground"
>
<p>No icons found</p>
{#if searchQuery}
<p class="text-[10px] text-muted-foreground/80 mt-0.5 font-mono">"{searchQuery}"</p>
{/if}
<Button
variant="ghost"
size="sm"
class="mt-3 h-7 text-[10px]"
onclick={() => {
searchQuery = '';
selectedPack = 'all';
}}
>
Clear filters
</Button>
</div>
{:else}
<div style="height: {totalHeight}px; position: relative;">
<div
style="position: absolute; top: 0; left: 0; right: 0; transform: translateY({startIndex *
rowHeight}px);"
>
{#if viewMode === 'grid'}
{#each visibleRows as row (row.index)}
<div class="grid grid-cols-5 gap-1.5" style="height: {rowHeight}px;">
{#each row.items ?? [] as item (item.id)}
{@const isSelected =
item.id === value || (item.pack === 'launcher' && item.name === value)}
<button
type="button"
class="group relative flex flex-col items-center justify-center rounded-md p-1 border transition-all text-center
{isSelected
? 'bg-primary/20 border-primary text-primary shadow-xs'
: 'border-transparent bg-theme-900/40 hover:bg-theme-800/80 hover:border-theme-700 text-theme-200 hover:text-theme-50'}"
title="{item.name} ({item.packLabel})"
onclick={() => selectIcon(item.id)}
>
<div class="flex size-5 items-center justify-center shrink-0">
<ThemeIcon name={item.id} set={iconSet} size={18} />
</div>
<span
class="mt-0.5 w-full truncate text-[9px] font-mono leading-tight opacity-75 group-hover:opacity-100"
>
{item.name}
</span>
{#if isSelected}
<div
class="absolute top-0.5 right-0.5 size-1.5 rounded-full bg-primary"
></div>
{/if}
</button>
{/each}
</div>
{/each}
{:else}
{#each visibleRows as row (row.index)}
{#if row.item}
{@const item = row.item}
{@const isSelected =
item.id === value || (item.pack === 'launcher' && item.name === value)}
<div style="height: {rowHeight}px;" class="flex items-center">
<button
type="button"
class="flex w-full h-7 items-center justify-between rounded px-2 text-xs transition-colors
{isSelected
? 'bg-primary/20 text-primary font-medium'
: 'text-theme-200 hover:bg-theme-800/70 hover:text-theme-50'}"
title="{item.name} ({item.packLabel})"
onclick={() => selectIcon(item.id)}
>
<div class="flex items-center gap-2 min-w-0">
<div class="flex size-4.5 items-center justify-center shrink-0">
<ThemeIcon name={item.id} set={iconSet} size={16} />
</div>
<span class="truncate font-mono text-[11px]">{item.name}</span>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<span
class="text-[9px] uppercase px-1 py-0.2 rounded bg-theme-900/80 border border-theme-800/80 text-muted-foreground font-mono"
>
{item.packLabel}
</span>
{#if isSelected}
<Check class="size-3 text-primary" />
{/if}
</div>
</button>
</div>
{/if}
{/each}
{/if}
</div>
</div>
{/if}
</div>
<!-- Footer Stats Bar -->
<div
class="flex items-center justify-between border-t border-theme-800/70 bg-theme-900/40 px-3 py-1.5 text-[10px] text-muted-foreground"
>
<span>{filteredItems.length.toLocaleString()} icons</span>
<span class="text-[9px]"
>Theme pack: <span class="capitalize text-theme-200">{iconSet}</span></span
>
</div>
</Popover.Content>
</Popover.Root>
+11 -5
View File
@@ -61,10 +61,14 @@
`font-family:${fontStack(bodyFont)}`,
`--primary:${accent}`,
`--primary-foreground:${accentText}`,
`--accent:${accent}`,
`--accent-foreground:${accentText}`,
`--ring:${accent}`,
`--color-primary:${accent}`,
`--color-primary-foreground:${accentText}`,
`--color-primary-500:${accent}`,
`--color-accent:${accent}`,
`--color-accent-foreground:${accentText}`,
`--color-ring:${accent}`,
`--font-mono:${monoFontStack(monoFont)}`,
].join(';')
@@ -91,11 +95,13 @@
<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>
{#if variables.launcher_version !== undefined && variables.launcher_version !== ''}
<span
class="rounded-full bg-primary px-2 py-0 text-[8px] font-semibold text-primary-foreground"
>
{variables.launcher_version}
</span>
{/if}
<div
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] bg-primary p-3 text-primary-foreground ring-1 ring-inset ring-white/15"
>
+169 -50
View File
@@ -2,7 +2,7 @@
import ThemeCustomLayer from '$lib/components/theme/ThemeCustomLayer.svelte';
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
import ThemeLottieLayer from '$lib/components/theme/ThemeLottieLayer.svelte';
import { resolveThemeDataValue, themeGlobalData } from '$lib/themeData';
import { getThemeDataSampleValue, resolveThemeDataValue, themeGlobalData } from '$lib/themeData';
import { fontStack } from '$lib/themeFonts';
import { DEFAULT_THEME_ICON_SET, type ThemeIconSet } from '$lib/themeIcons';
import {
@@ -42,6 +42,7 @@
import { Boxes, ChevronRight, Move, RotateCw } from '@lucide/svelte';
import { onDestroy } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { TextMorph } from 'torph/svelte';
let {
elements = [],
@@ -155,8 +156,32 @@
mode: element.mode,
now: clockNow,
});
const fallback = element.defaultValue ?? '';
const sample = interactive ? getThemeDataSampleValue(element.field, element.mode) : '';
const value =
raw && raw.trim() !== '' ? raw : fallback && fallback.trim() !== '' ? fallback : sample;
const template = element.text?.trim() ? element.text : '{value}';
return interpolateThemeText(template.replaceAll('{value}', raw), variables);
return interpolateThemeText(
template
.replaceAll('{value}', value)
.replaceAll('{defaultValue}', fallback)
.replaceAll('{fallback}', fallback),
variables
);
};
const isMorphEnabled = (element: ThemeV2Element): boolean => {
if (element.morph === true) return true;
if (element.morph === false) return false;
return ancestorsOf(element).some((group) => group.morph === true);
};
const morphDuration = (element: ThemeV2Element): number => {
if (element.morphDuration && element.morphDuration > 0) return element.morphDuration;
for (const group of ancestorsOf(element)) {
if (group.morphDuration && group.morphDuration > 0) return group.morphDuration;
}
return 400;
};
const maskedAncestors = (element: ThemeV2Element) =>
@@ -1232,6 +1257,10 @@
)}"
>
{#if element.type === 'text' || element.type === 'data'}
{@const content =
element.type === 'data'
? dataValue(element)
: interpolateThemeText(element.text, variables)}
<div
class="h-full w-full overflow-hidden"
style="display:flex;align-items:{element.verticalAlign === 'center'
@@ -1244,16 +1273,26 @@
? '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};line-height:{element.lineHeight ??
1.2};letter-spacing:{element.letterSpacing ??
0}px;white-space:pre-wrap;overflow-wrap:anywhere;{themeTextStyleCss(element)}"
>
{element.type === 'data'
? dataValue(element)
: interpolateThemeText(element.text, variables)}
</span>
{#if isMorphEnabled(element)}
<TextMorph
text={String(content ?? '')}
duration={morphDuration(element)}
as="span"
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
28}px;font-weight:{element.fontWeight ?? 600};line-height:{element.lineHeight ??
1.2};letter-spacing:{element.letterSpacing ??
0}px;white-space:pre-wrap;overflow-wrap:anywhere;{themeTextStyleCss(element)}"
/>
{:else}
<span
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
28}px;font-weight:{element.fontWeight ?? 600};line-height:{element.lineHeight ??
1.2};letter-spacing:{element.letterSpacing ??
0}px;white-space:pre-wrap;overflow-wrap:anywhere;{themeTextStyleCss(element)}"
>
{content}
</span>
{/if}
</div>
{:else if element.type === 'image'}
{#if element.tint}
@@ -1420,14 +1459,27 @@
/>
</svg>
{#if gaugeLabel(element)}
<span
class="pointer-events-none absolute text-center"
style="color:{element.labelColor ?? '#ffffff'};font-family:{fontStack(
element.fontFamily
)};font-size:{element.fontSize ?? 20}px;font-weight:700;"
>
{gaugeLabel(element)}
</span>
{@const label = gaugeLabel(element)}
{#if isMorphEnabled(element)}
<TextMorph
text={String(label ?? '')}
duration={morphDuration(element)}
as="span"
class="pointer-events-none absolute text-center"
style="color:{element.labelColor ?? '#ffffff'};font-family:{fontStack(
element.fontFamily
)};font-size:{element.fontSize ?? 20}px;font-weight:700;"
/>
{:else}
<span
class="pointer-events-none absolute text-center"
style="color:{element.labelColor ?? '#ffffff'};font-family:{fontStack(
element.fontFamily
)};font-size:{element.fontSize ?? 20}px;font-weight:700;"
>
{label}
</span>
{/if}
{/if}
</div>
{:else}
@@ -1444,14 +1496,27 @@
></div>
</div>
{#if gaugeLabel(element)}
<span
class="pointer-events-none absolute inset-0 flex items-center justify-center"
style="color:{element.labelColor ?? '#ffffff'};font-family:{fontStack(
element.fontFamily
)};font-size:{element.fontSize ?? 20}px;font-weight:700;"
>
{gaugeLabel(element)}
</span>
{@const label = gaugeLabel(element)}
{#if isMorphEnabled(element)}
<TextMorph
text={String(label ?? '')}
duration={morphDuration(element)}
as="span"
class="pointer-events-none absolute inset-0 flex items-center justify-center"
style="color:{element.labelColor ?? '#ffffff'};font-family:{fontStack(
element.fontFamily
)};font-size:{element.fontSize ?? 20}px;font-weight:700;"
/>
{:else}
<span
class="pointer-events-none absolute inset-0 flex items-center justify-center"
style="color:{element.labelColor ?? '#ffffff'};font-family:{fontStack(
element.fontFamily
)};font-size:{element.fontSize ?? 20}px;font-weight:700;"
>
{label}
</span>
{/if}
{/if}
</div>
{/if}
@@ -1472,7 +1537,15 @@
600};color:{element.color ?? '#ffffff'};text-transform:{element.textTransform ??
'none'};white-space:nowrap;"
>
{interpolateThemeText(element.text, variables)}
{#if isMorphEnabled(element)}
<TextMorph
text={String(interpolateThemeText(element.text, variables) ?? '')}
duration={morphDuration(element)}
as="span"
/>
{:else}
{interpolateThemeText(element.text, variables)}
{/if}
</span>
{/each}
</div>
@@ -1506,7 +1579,16 @@
{#if element.icon}
<ThemeIcon name={element.icon} set={iconSet} size="1.1em" />
{/if}
<span class="truncate">{interpolateThemeText(element.label, variables)}</span>
{#if isMorphEnabled(element)}
<TextMorph
text={String(interpolateThemeText(element.label, variables) ?? '')}
duration={morphDuration(element)}
as="span"
class="truncate"
/>
{:else}
<span class="truncate">{interpolateThemeText(element.label, variables)}</span>
{/if}
</button>
{:else if element.type === 'path'}
{@const gradientVector = themeSvgGradientVector(element.gradientAngle ?? 135)}
@@ -1646,21 +1728,46 @@
'#ff006e'};">{element.label}</span
>
{/if}
<span
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
40}px;font-weight:{element.fontWeight ??
700};line-height:1.05;color:{element.color ??
'#ffffff'};font-variant-numeric:tabular-nums;"
>
{themeClockTime(element, now)}
</span>
{#if element.showDate}
{#if isMorphEnabled(element)}
<TextMorph
text={String(themeClockTime(element, now) ?? '')}
duration={morphDuration(element)}
as="span"
class="block"
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
40}px;font-weight:{element.fontWeight ??
700};line-height:1.05;color:{element.color ??
'#ffffff'};font-variant-numeric:tabular-nums;"
/>
{:else}
<span
style="font-family:{fontStack(element.fontFamily)};font-size:{clockCaptionSize(
element
)}px;font-weight:500;color:{element.accentColor ?? '#ff006e'};"
>{themeClockDate(now)}</span
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
40}px;font-weight:{element.fontWeight ??
700};line-height:1.05;color:{element.color ??
'#ffffff'};font-variant-numeric:tabular-nums;"
>
{themeClockTime(element, now)}
</span>
{/if}
{#if element.showDate}
{#if isMorphEnabled(element)}
<TextMorph
text={String(themeClockDate(now) ?? '')}
duration={morphDuration(element)}
as="span"
class="block"
style="font-family:{fontStack(element.fontFamily)};font-size:{clockCaptionSize(
element
)}px;font-weight:500;color:{element.accentColor ?? '#ff006e'};"
/>
{:else}
<span
style="font-family:{fontStack(element.fontFamily)};font-size:{clockCaptionSize(
element
)}px;font-weight:500;color:{element.accentColor ?? '#ff006e'};"
>{themeClockDate(now)}</span
>
{/if}
{/if}
</div>
{/if}
@@ -1673,13 +1780,25 @@
? 'flex-end'
: 'center'};text-align:{element.align ?? 'center'};"
>
<span
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
40}px;font-weight:{element.fontWeight ?? 700};color:{element.color ??
'#ffffff'};font-variant-numeric:tabular-nums;"
>
{themeCountdownText(element, clockNow)}
</span>
{#if isMorphEnabled(element)}
<TextMorph
text={String(themeCountdownText(element, clockNow) ?? '')}
duration={morphDuration(element)}
as="span"
class="block"
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
40}px;font-weight:{element.fontWeight ?? 700};color:{element.color ??
'#ffffff'};font-variant-numeric:tabular-nums;"
/>
{:else}
<span
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
40}px;font-weight:{element.fontWeight ?? 700};color:{element.color ??
'#ffffff'};font-variant-numeric:tabular-nums;"
>
{themeCountdownText(element, clockNow)}
</span>
{/if}
</div>
{/if}
</div>
+22
View File
@@ -0,0 +1,22 @@
import Root from "./tooltip.svelte";
import Content from "./tooltip-content.svelte";
import Trigger from "./tooltip-trigger.svelte";
import Provider from "./tooltip-provider.svelte";
import Portal from "./tooltip-portal.svelte";
import Arrow from "./tooltip-arrow.svelte";
export {
Root,
Content,
Trigger,
Provider,
Portal,
Arrow,
//
Root as Tooltip,
Content as TooltipContent,
Trigger as TooltipTrigger,
Provider as TooltipProvider,
Portal as TooltipPortal,
Arrow as TooltipArrow,
};
@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Tooltip as TooltipPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
...restProps
}: TooltipPrimitive.ArrowProps = $props();
</script>
<TooltipPrimitive.Arrow
bind:ref
data-slot="tooltip-arrow"
class={cn('text-popover fill-popover', className)}
{...restProps}
/>
@@ -0,0 +1,29 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
import TooltipPortal from "./tooltip-portal.svelte";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
sideOffset = 6,
portalProps,
...restProps
}: TooltipPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof TooltipPortal>>;
} = $props();
</script>
<TooltipPortal {...portalProps}>
<TooltipPrimitive.Content
bind:ref
data-slot="tooltip-content"
{sideOffset}
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 overflow-hidden rounded-md border border-border px-3 py-1.5 text-xs shadow-md",
className
)}
{...restProps}
/>
</TooltipPortal>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
let { ...restProps }: TooltipPrimitive.PortalProps = $props();
</script>
<TooltipPrimitive.Portal {...restProps} />
@@ -0,0 +1,13 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
let {
delayDuration = 100,
...restProps
}: TooltipPrimitive.ProviderProps = $props();
</script>
<TooltipPrimitive.Provider
{delayDuration}
{...restProps}
/>
@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Tooltip as TooltipPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
...restProps
}: TooltipPrimitive.TriggerProps = $props();
</script>
<TooltipPrimitive.Trigger
bind:ref
data-slot="tooltip-trigger"
class={cn("", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: TooltipPrimitive.RootProps = $props();
</script>
<TooltipPrimitive.Root bind:open {...restProps} />