chore(lint): resolve launcher lint issues

Format files and fix unused code, reactive collection usage, redundant mustaches, and missing each-block keys.
This commit is contained in:
HorizonCode committed 2026-11-03 16:21:23 +01:00
1 parent 3ef59f94ee
commit 93536af6a6
10 files changed
+1860 -1763

No files matched your search

+10 -6
View File
@@ -1,7 +1,3 @@
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import * as path from '@tauri-apps/api/path';
import * as fs from '@tauri-apps/plugin-fs';
import { config } from './config'; import { config } from './config';
import { import {
reloadThemes, reloadThemes,
@@ -9,6 +5,10 @@ import {
updateInstalledThemeTags, updateInstalledThemeTags,
updateInstalledThemeWorkshopId, updateInstalledThemeWorkshopId,
} from './themes'; } from './themes';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import * as path from '@tauri-apps/api/path';
import * as fs from '@tauri-apps/plugin-fs';
export interface CommunityThemeAuthor { export interface CommunityThemeAuthor {
id: number; id: number;
@@ -159,7 +159,9 @@ const DEFAULT_THEMES_SERVICE_URL = 'https://themes.ez-pp.farm';
export async function getThemesServiceUrl(): Promise<string> { export async function getThemesServiceUrl(): Promise<string> {
const customUrl = await config.value<string>('themes_service_url').get(''); const customUrl = await config.value<string>('themes_service_url').get('');
return customUrl.trim().length > 0 ? customUrl.trim().replace(/\/+$/, '') : DEFAULT_THEMES_SERVICE_URL; return customUrl.trim().length > 0
? customUrl.trim().replace(/\/+$/, '')
: DEFAULT_THEMES_SERVICE_URL;
} }
/** /**
@@ -273,7 +275,9 @@ export async function fetchCommunityThemeDetails(themeId: string): Promise<Commu
return item; return item;
} }
export async function toggleCommunityThemeLike(themeId: string): Promise<{ liked: boolean; likeCount: number }> { export async function toggleCommunityThemeLike(
themeId: string
): Promise<{ liked: boolean; likeCount: number }> {
const baseUrl = await getThemesServiceUrl(); const baseUrl = await getThemesServiceUrl();
const headers = await getAuthHeaders(); const headers = await getAuthHeaders();
@@ -1,8 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Bell, CheckCheck, LoaderCircle, Megaphone, RefreshCw, ShieldAlert, X } from '@lucide/svelte';
import { Portal } from 'bits-ui';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { currentUser } from '$lib/userAuthentication';
import { import {
fetchNotifications, fetchNotifications,
fetchUnreadNotificationCount, fetchUnreadNotificationCount,
@@ -10,6 +7,17 @@
markNotificationRead, markNotificationRead,
type UserNotification, type UserNotification,
} from '$lib/notifications'; } from '$lib/notifications';
import { currentUser } from '$lib/userAuthentication';
import {
Bell,
CheckCheck,
LoaderCircle,
Megaphone,
RefreshCw,
ShieldAlert,
X,
} from '@lucide/svelte';
import { Portal } from 'bits-ui';
let open = $state(false); let open = $state(false);
let loading = $state(false); let loading = $state(false);
@@ -80,7 +88,10 @@
beforeId: notifications[notifications.length - 1].id, beforeId: notifications[notifications.length - 1].id,
}); });
const known = new Set(notifications.map((item) => item.id)); const known = new Set(notifications.map((item) => item.id));
notifications = [...notifications, ...data.notifications.filter((item) => !known.has(item.id))]; notifications = [
...notifications,
...data.notifications.filter((item) => !known.has(item.id)),
];
unreadCount = data.unreadCount; unreadCount = data.unreadCount;
hasMore = data.hasMore; hasMore = data.hasMore;
} catch (err) { } catch (err) {
@@ -149,158 +160,165 @@
{#if open} {#if open}
<Portal> <Portal>
<button <button
type="button" type="button"
class="fixed inset-0 z-[80] cursor-default bg-black/30 backdrop-blur-[1px]" class="fixed inset-0 z-[80] cursor-default bg-black/30 backdrop-blur-[1px]"
aria-label="Close notifications" aria-label="Close notifications"
onclick={() => (open = false)} onclick={() => (open = false)}
></button> ></button>
<div <div
class="fixed bottom-4 left-[4.75rem] z-[90] flex max-h-[min(34rem,calc(100vh-2rem))] w-[22rem] flex-col overflow-hidden rounded-xl border border-theme-800 bg-theme-950 shadow-2xl" class="fixed bottom-4 left-[4.75rem] z-[90] flex max-h-[min(34rem,calc(100vh-2rem))] w-[22rem] flex-col overflow-hidden rounded-xl border border-theme-800 bg-theme-950 shadow-2xl"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="notification-hub-title" aria-labelledby="notification-hub-title"
> >
<div class="flex items-start justify-between gap-3 border-b border-theme-800/70 p-4"> <div class="flex items-start justify-between gap-3 border-b border-theme-800/70 p-4">
<div> <div>
<h2 id="notification-hub-title" class="flex items-center gap-2 text-sm font-semibold text-white"> <h2
<Bell class="size-4 text-primary" /> id="notification-hub-title"
Notifications class="flex items-center gap-2 text-sm font-semibold text-white"
{#if unreadCount > 0} >
<span class="rounded-full bg-primary/20 px-1.5 py-0.5 text-[10px] text-primary"> <Bell class="size-4 text-primary" />
{unreadCount} new Notifications
</span> {#if unreadCount > 0}
{/if} <span class="rounded-full bg-primary/20 px-1.5 py-0.5 text-[10px] text-primary">
</h2> {unreadCount} new
<p class="mt-1 text-[11px] text-muted-foreground">Moderation updates and system messages.</p> </span>
</div> {/if}
<div class="flex items-center gap-1"> </h2>
<Button <p class="mt-1 text-[11px] text-muted-foreground">
variant="ghost" Moderation updates and system messages.
size="icon"
class="size-7 text-muted-foreground hover:text-white"
title="Refresh notifications"
onclick={() => void loadNotifications()}
disabled={loading}
>
<RefreshCw class="size-3.5 {loading ? 'animate-spin' : ''}" />
</Button>
<Button
variant="ghost"
size="icon"
class="size-7 text-muted-foreground hover:text-white"
title="Close"
onclick={() => (open = false)}
>
<X class="size-4" />
</Button>
</div>
</div>
<div class="flex items-center justify-between border-b border-theme-800/50 px-4 py-2">
<span class="text-[10px] text-muted-foreground">
{unreadCount > 0 ? `${unreadCount} unread` : 'All caught up'}
</span>
<Button
variant="ghost"
size="sm"
class="h-6 gap-1 px-1.5 text-[10px] text-muted-foreground hover:text-white"
onclick={() => void markAllRead()}
disabled={markingAll || unreadCount === 0}
>
{#if markingAll}
<LoaderCircle class="size-3 animate-spin" />
{:else}
<CheckCheck class="size-3" />
{/if}
Mark all read
</Button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-2">
{#if error}
<div class="m-2 rounded-lg border border-red-900/40 bg-red-950/20 p-3 text-[11px] text-red-300">
{error}
</div>
{/if}
{#if loading && notifications.length === 0}
<div class="flex h-40 items-center justify-center gap-2 text-xs text-muted-foreground">
<LoaderCircle class="size-4 animate-spin text-primary" /> Loading notifications...
</div>
{:else if notifications.length === 0}
<div class="flex h-40 flex-col items-center justify-center gap-2 px-6 text-center">
<Bell class="size-7 text-muted-foreground/40" />
<p class="text-xs text-muted-foreground">No notifications yet.</p>
<p class="text-[10px] text-muted-foreground/70">
Moderation decisions about your themes and reports will appear here.
</p> </p>
</div> </div>
{:else} <div class="flex items-center gap-1">
<div class="space-y-1"> <Button
{#each notifications as notification (notification.id)} variant="ghost"
<button size="icon"
type="button" class="size-7 text-muted-foreground hover:text-white"
class="w-full rounded-lg border p-3 text-left transition-colors {notification.readAt title="Refresh notifications"
? 'border-transparent bg-transparent hover:bg-theme-900/50' onclick={() => void loadNotifications()}
: 'border-primary/20 bg-primary/5 hover:bg-primary/10'}" disabled={loading}
onclick={() => void markRead(notification)} >
> <RefreshCw class="size-3.5 {loading ? 'animate-spin' : ''}" />
<div class="flex items-start gap-2.5"> </Button>
<div <Button
class="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full {notification.type === variant="ghost"
'theme_hidden' || notification.type === 'theme_taken_down' size="icon"
? 'bg-red-500/10 text-red-300' class="size-7 text-muted-foreground hover:text-white"
: notification.type === 'report_resolved' title="Close"
? 'bg-emerald-500/10 text-emerald-300' onclick={() => (open = false)}
: notification.type === 'system' >
? 'bg-cyan-500/10 text-cyan-300' <X class="size-4" />
: 'bg-amber-500/10 text-amber-300'}" </Button>
>
{#if notification.type === 'system'}
<Megaphone class="size-3.5" />
{:else}
<ShieldAlert class="size-3.5" />
{/if}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<p class="text-[11px] font-semibold text-white">{notification.title}</p>
{#if !notification.readAt}
<span class="mt-1 size-1.5 shrink-0 rounded-full bg-primary"></span>
{/if}
</div>
<p class="mt-1 text-[10px] leading-relaxed text-muted-foreground">
{notification.message}
</p>
<p class="mt-1.5 text-[9px] text-muted-foreground/70">
{formatDate(notification.createdAt)}
</p>
</div>
</div>
</button>
{/each}
</div> </div>
</div>
{#if hasMore} <div class="flex items-center justify-between border-b border-theme-800/50 px-4 py-2">
<div class="flex justify-center p-2"> <span class="text-[10px] text-muted-foreground">
<Button {unreadCount > 0 ? `${unreadCount} unread` : 'All caught up'}
variant="ghost" </span>
size="sm" <Button
class="h-7 text-[10px] text-muted-foreground" variant="ghost"
onclick={() => void loadOlder()} size="sm"
disabled={loadingMore} class="h-6 gap-1 px-1.5 text-[10px] text-muted-foreground hover:text-white"
> onclick={() => void markAllRead()}
{#if loadingMore} disabled={markingAll || unreadCount === 0}
<LoaderCircle class="mr-1 size-3 animate-spin" /> >
{/if} {#if markingAll}
Load older <LoaderCircle class="size-3 animate-spin" />
</Button> {:else}
<CheckCheck class="size-3" />
{/if}
Mark all read
</Button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-2">
{#if error}
<div
class="m-2 rounded-lg border border-red-900/40 bg-red-950/20 p-3 text-[11px] text-red-300"
>
{error}
</div> </div>
{/if} {/if}
{/if}
{#if loading && notifications.length === 0}
<div class="flex h-40 items-center justify-center gap-2 text-xs text-muted-foreground">
<LoaderCircle class="size-4 animate-spin text-primary" /> Loading notifications...
</div>
{:else if notifications.length === 0}
<div class="flex h-40 flex-col items-center justify-center gap-2 px-6 text-center">
<Bell class="size-7 text-muted-foreground/40" />
<p class="text-xs text-muted-foreground">No notifications yet.</p>
<p class="text-[10px] text-muted-foreground/70">
Moderation decisions about your themes and reports will appear here.
</p>
</div>
{:else}
<div class="space-y-1">
{#each notifications as notification (notification.id)}
<button
type="button"
class="w-full rounded-lg border p-3 text-left transition-colors {notification.readAt
? 'border-transparent bg-transparent hover:bg-theme-900/50'
: 'border-primary/20 bg-primary/5 hover:bg-primary/10'}"
onclick={() => void markRead(notification)}
>
<div class="flex items-start gap-2.5">
<div
class="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full {notification.type ===
'theme_hidden' || notification.type === 'theme_taken_down'
? 'bg-red-500/10 text-red-300'
: notification.type === 'report_resolved'
? 'bg-emerald-500/10 text-emerald-300'
: notification.type === 'system'
? 'bg-cyan-500/10 text-cyan-300'
: 'bg-amber-500/10 text-amber-300'}"
>
{#if notification.type === 'system'}
<Megaphone class="size-3.5" />
{:else}
<ShieldAlert class="size-3.5" />
{/if}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<p class="text-[11px] font-semibold text-white">{notification.title}</p>
{#if !notification.readAt}
<span class="mt-1 size-1.5 shrink-0 rounded-full bg-primary"></span>
{/if}
</div>
<p class="mt-1 text-[10px] leading-relaxed text-muted-foreground">
{notification.message}
</p>
<p class="mt-1.5 text-[9px] text-muted-foreground/70">
{formatDate(notification.createdAt)}
</p>
</div>
</div>
</button>
{/each}
</div>
{#if hasMore}
<div class="flex justify-center p-2">
<Button
variant="ghost"
size="sm"
class="h-7 text-[10px] text-muted-foreground"
onclick={() => void loadOlder()}
disabled={loadingMore}
>
{#if loadingMore}
<LoaderCircle class="mr-1 size-3 animate-spin" />
{/if}
Load older
</Button>
</div>
{/if}
{/if}
</div>
</div> </div>
</div>
</Portal> </Portal>
{/if} {/if}
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import Logo from '$assets/logo.png'; import Logo from '$assets/logo.png';
import { publishThemeToWorkshop } from '$lib/communityThemes';
import FontPreview from '$lib/components/theme/FontPreview.svelte'; import FontPreview from '$lib/components/theme/FontPreview.svelte';
import ThemeBehaviorInspector from '$lib/components/theme/ThemeBehaviorInspector.svelte'; import ThemeBehaviorInspector from '$lib/components/theme/ThemeBehaviorInspector.svelte';
import ThemeElementInspector from '$lib/components/theme/ThemeElementInspector.svelte'; import ThemeElementInspector from '$lib/components/theme/ThemeElementInspector.svelte';
@@ -102,7 +103,6 @@
toSafeName, toSafeName,
updateInstalledThemeWorkshopId, updateInstalledThemeWorkshopId,
} from '$lib/themes'; } from '$lib/themes';
import { publishThemeToWorkshop } from '$lib/communityThemes';
import type { EZPPUser, EZPPUserInfo } from '$lib/types'; import type { EZPPUser, EZPPUserInfo } from '$lib/types';
import { currentUser } from '$lib/userAuthentication'; import { currentUser } from '$lib/userAuthentication';
import { osuInstallationPath } from '$lib/userSettings'; import { osuInstallationPath } from '$lib/userSettings';
@@ -5091,8 +5091,8 @@
Allow forking Allow forking
</Label> </Label>
<p class="text-[10px] leading-relaxed text-muted-foreground"> <p class="text-[10px] leading-relaxed text-muted-foreground">
Let other creators fork this theme from the workshop. Takes effect when you publish Let other creators fork this theme from the workshop. Takes effect when you
or update it; existing forks are not affected. publish or update it; existing forks are not affected.
</p> </p>
</div> </div>
<Checkbox <Checkbox
@@ -6900,7 +6900,9 @@
</div> </div>
{#if $currentUser} {#if $currentUser}
<div class="mt-4 flex items-center justify-between rounded-xl border border-theme-800/70 bg-theme-900/40 px-3.5 py-2.5"> <div
class="mt-4 flex items-center justify-between rounded-xl border border-theme-800/70 bg-theme-900/40 px-3.5 py-2.5"
>
<div class="flex items-center gap-2.5"> <div class="flex items-center gap-2.5">
<img <img
src={`https://a.ez-pp.farm/${$currentUser.id}`} src={`https://a.ez-pp.farm/${$currentUser.id}`}
@@ -6914,7 +6916,9 @@
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<span class="text-xs font-semibold text-white">{$currentUser.name}</span> <span class="text-xs font-semibold text-white">{$currentUser.name}</span>
{#if $currentUser.donor} {#if $currentUser.donor}
<span class="rounded bg-amber-500/20 px-1 py-0.2 text-[8px] font-bold text-amber-300 border border-amber-500/30"> <span
class="rounded bg-amber-500/20 px-1 py-0.2 text-[8px] font-bold text-amber-300 border border-amber-500/30"
>
Supporter Supporter
</span> </span>
{/if} {/if}
@@ -6953,7 +6957,9 @@
{#if publishThemeId} {#if publishThemeId}
<p class="mt-1 truncate font-mono text-[11px] text-theme-100">{publishThemeId}</p> <p class="mt-1 truncate font-mono text-[11px] text-theme-100">{publishThemeId}</p>
{:else} {:else}
<p class="mt-1 text-[11px] text-muted-foreground">Assigned by EZPPFarm on publish</p> <p class="mt-1 text-[11px] text-muted-foreground">
Assigned by EZPPFarm on publish
</p>
{/if} {/if}
</div> </div>
<div> <div>
@@ -6962,7 +6968,7 @@
</span> </span>
{#if (definition.tags ?? []).length > 0} {#if (definition.tags ?? []).length > 0}
<div class="mt-1 flex flex-wrap gap-1"> <div class="mt-1 flex flex-wrap gap-1">
{#each definition.tags ?? [] as tag} {#each definition.tags ?? [] as tag (tag)}
<span class="rounded bg-theme-800 px-1.5 py-0.5 text-[10px] text-theme-100"> <span class="rounded bg-theme-800 px-1.5 py-0.5 text-[10px] text-theme-100">
{tag} {tag}
</span> </span>
@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import { Flag, LoaderCircle, X } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
import Textarea from '$lib/components/ui/textarea/textarea.svelte';
import { import {
reportCommunityTheme, reportCommunityTheme,
type CommunityTheme, type CommunityTheme,
type ThemeReportCategory, type ThemeReportCategory,
} from '$lib/communityThemes'; } from '$lib/communityThemes';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
import Textarea from '$lib/components/ui/textarea/textarea.svelte';
import { Flag, LoaderCircle, X } from '@lucide/svelte';
import { sileo } from 'sileo'; import { sileo } from 'sileo';
let { let {
@@ -79,8 +79,8 @@
<Flag class="size-4 text-amber-300" /> Report theme <Flag class="size-4 text-amber-300" /> Report theme
</Dialog.Title> </Dialog.Title>
<Dialog.Description class="mt-1 text-[11px] text-muted-foreground"> <Dialog.Description class="mt-1 text-[11px] text-muted-foreground">
Reporting <span class="text-white/80">{theme.name}</span> by {theme.author.name}. Reports Reporting <span class="text-white/80">{theme.name}</span> by {theme.author.name}.
are visible to moderators only. Reports are visible to moderators only.
</Dialog.Description> </Dialog.Description>
</div> </div>
<Button <Button
@@ -97,7 +97,10 @@
<div class="space-y-3 p-4"> <div class="space-y-3 p-4">
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="installed-report-category" class="text-[10px] uppercase tracking-wide text-white/70"> <Label
for="installed-report-category"
class="text-[10px] uppercase tracking-wide text-white/70"
>
Category Category
</Label> </Label>
<Select.Root <Select.Root
@@ -122,7 +125,10 @@
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="installed-report-details" class="text-[10px] uppercase tracking-wide text-white/70"> <Label
for="installed-report-details"
class="text-[10px] uppercase tracking-wide text-white/70"
>
Details Details
</Label> </Label>
<Textarea <Textarea
@@ -141,7 +147,9 @@
</div> </div>
{#if error} {#if error}
<p class="rounded-lg border border-red-900/40 bg-red-950/20 p-2.5 text-[11px] text-red-300"> <p
class="rounded-lg border border-red-900/40 bg-red-950/20 p-2.5 text-[11px] text-red-300"
>
{error} {error}
</p> </p>
{/if} {/if}
+2 -4
View File
@@ -1,10 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive, type WithoutChildrenOrChild } from 'bits-ui'; import { Dialog as DialogPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
let { let { open = $bindable(false), ...restProps }: WithoutChildrenOrChild<DialogPrimitive.RootProps> =
open = $bindable(false), $props();
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.RootProps> = $props();
</script> </script>
<DialogPrimitive.Root bind:open {...restProps} /> <DialogPrimitive.Root bind:open {...restProps} />
+1 -1
View File
@@ -13,10 +13,10 @@
* theme videos and videos picked in the editor (served as /preview/ URLs) are * theme videos and videos picked in the editor (served as /preview/ URLs) are
* transcoded to a cached H.264 copy by the Rust backend before playback. * transcoded to a cached H.264 copy by the Rust backend before playback.
*/ */
import { custom_theme_volume } from './global';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { get, writable } from 'svelte/store'; import { get, writable } from 'svelte/store';
import { custom_theme_volume } from './global';
type MediaCleanup = () => void; type MediaCleanup = () => void;
+7 -20
View File
@@ -506,7 +506,9 @@ export const reloadThemes = async () => {
const ACTIVE_STATUSES = new Set(['downloading', 'extracting', 'deleting']); const ACTIVE_STATUSES = new Set(['downloading', 'extracting', 'deleting']);
const updatedThemes = storeThemes const updatedThemes = storeThemes
.filter((storeTheme) => installedMap.has(storeTheme.name) || ACTIVE_STATUSES.has(storeTheme.status)) .filter(
(storeTheme) => installedMap.has(storeTheme.name) || ACTIVE_STATUSES.has(storeTheme.status)
)
.map((storeTheme) => { .map((storeTheme) => {
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);
@@ -564,10 +566,7 @@ export const saveThemeProject = async (
return themeFolder; return themeFolder;
}; };
const updateInstalledThemeManifest = async ( const updateInstalledThemeManifest = async (folderName: string, patch: Record<string, unknown>) => {
folderName: string,
patch: Record<string, unknown>
) => {
const themeFolder = await path.join(await themesDirectory(), folderName); const themeFolder = await path.join(await themesDirectory(), folderName);
const configPath = await path.join(themeFolder, 'theme.json'); const configPath = await path.join(themeFolder, 'theme.json');
if (!(await fs.exists(configPath))) { if (!(await fs.exists(configPath))) {
@@ -585,10 +584,7 @@ const updateInstalledThemeManifest = async (
} }
const updated = { ...(parsed as Record<string, unknown>), ...patch }; const updated = { ...(parsed as Record<string, unknown>), ...patch };
await fs.writeFile( await fs.writeFile(configPath, new TextEncoder().encode(`${JSON.stringify(updated, null, 2)}\n`));
configPath,
new TextEncoder().encode(`${JSON.stringify(updated, null, 2)}\n`)
);
}; };
/** Write workshop tags into an installed theme's manifest without dropping other fields. */ /** Write workshop tags into an installed theme's manifest without dropping other fields. */
@@ -596,10 +592,8 @@ export const updateInstalledThemeTags = async (folderName: string, tags: string[
updateInstalledThemeManifest(folderName, { tags }); updateInstalledThemeManifest(folderName, { tags });
/** Remember the server-assigned workshop ID so later publishes update the same theme. */ /** Remember the server-assigned workshop ID so later publishes update the same theme. */
export const updateInstalledThemeWorkshopId = async ( export const updateInstalledThemeWorkshopId = async (folderName: string, workshopId: string) =>
folderName: string, updateInstalledThemeManifest(folderName, { workshopId });
workshopId: string
) => updateInstalledThemeManifest(folderName, { workshopId });
export const exportThemeFolder = async (folderName: string, destinationPath: string) => { export const exportThemeFolder = async (folderName: string, destinationPath: string) => {
const themeFolder = await path.join(await themesDirectory(), folderName); const themeFolder = await path.join(await themesDirectory(), folderName);
@@ -632,10 +626,3 @@ export const toSafeName = (name: string) => {
.replace(/[. ]+$/g, '_') .replace(/[. ]+$/g, '_')
); );
}; };
const generateRandomString = (length: number) => {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
return Array.from({ length }, () =>
characters.charAt(Math.floor(Math.random() * characters.length))
).join('');
};
+1 -3
View File
@@ -266,9 +266,7 @@ export function trackMediaContainer(node: HTMLElement) {
const media = new Set<HTMLMediaElement>(); const media = new Set<HTMLMediaElement>();
function update() { function update() {
custom_theme_audio_playing.set( custom_theme_audio_playing.set(Array.from(media).some((el) => !el.paused));
Array.from(media).some((el) => !el.paused)
);
} }
function register(el: HTMLMediaElement) { function register(el: HTMLMediaElement) {
+311 -303
View File
@@ -11,6 +11,7 @@
import LogViewer from '$lib/components/logs/LogViewer.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 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';
@@ -26,7 +27,6 @@
import Progress from '$lib/components/ui/progress/progress.svelte'; import Progress from '$lib/components/ui/progress/progress.svelte';
import ScrollContainer from '$lib/components/ui/scroll-container/ScrollContainer.svelte'; import ScrollContainer from '$lib/components/ui/scroll-container/ScrollContainer.svelte';
import * as Select from '$lib/components/ui/select'; import * as Select from '$lib/components/ui/select';
import CommunityWorkshopView from '$lib/components/theme/CommunityWorkshopView.svelte';
import { config } from '$lib/config'; import { config } from '$lib/config';
import { currentUserInfo } from '$lib/data'; import { currentUserInfo } from '$lib/data';
import { import {
@@ -180,6 +180,7 @@
import { animate } from 'animejs'; import { animate } from 'animejs';
import { sileo } from 'sileo'; import { sileo } from 'sileo';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { 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';
@@ -355,7 +356,7 @@
}); });
const currentUserAliases = $derived.by(() => { const currentUserAliases = $derived.by(() => {
const aliases = new Set<string>(); const aliases = new SvelteSet<string>();
const add = (value: string | undefined) => { const add = (value: string | undefined) => {
const normalized = value?.trim().toLowerCase(); const normalized = value?.trim().toLowerCase();
if (normalized) aliases.add(normalized); if (normalized) aliases.add(normalized);
@@ -371,8 +372,8 @@
const loadOwnedWorkshopThemes = async (userId: number) => { const loadOwnedWorkshopThemes = async (userId: number) => {
const requestId = ++ownedThemesRequestId; const requestId = ++ownedThemesRequestId;
ownedWorkshopLoaded = false; ownedWorkshopLoaded = false;
const ids = new Set<string>(); const ids = new SvelteSet<string>();
const names = new Set<string>(); const names = new SvelteSet<string>();
try { try {
let page = 1; let page = 1;
@@ -3765,10 +3766,13 @@
</div> </div>
<!-- Tab Switcher: Installed vs Community Workshop --> <!-- Tab Switcher: Installed vs Community Workshop -->
<div class="flex items-center rounded-lg border border-theme-800/60 bg-theme-900/50 p-0.5"> <div
class="flex items-center rounded-lg border border-theme-800/60 bg-theme-900/50 p-0.5"
>
<button <button
type="button" type="button"
class="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors {activeThemesTab === 'library' class="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors {activeThemesTab ===
'library'
? 'bg-primary text-primary-foreground shadow-sm' ? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-white'}" : 'text-muted-foreground hover:text-white'}"
onclick={() => (activeThemesTab = 'library')} onclick={() => (activeThemesTab = 'library')}
@@ -3781,7 +3785,8 @@
</button> </button>
<button <button
type="button" type="button"
class="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors {activeThemesTab === 'workshop' class="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors {activeThemesTab ===
'workshop'
? 'bg-primary text-primary-foreground shadow-sm' ? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-white'}" : 'text-muted-foreground hover:text-white'}"
onclick={() => (activeThemesTab = 'workshop')} onclick={() => (activeThemesTab = 'workshop')}
@@ -3891,325 +3896,328 @@
/> />
{:else} {:else}
<ScrollContainer class="pr-2"> <ScrollContainer class="pr-2">
{#if filteredThemes.length > 0} {#if filteredThemes.length > 0}
{#key themeFilter} {#key themeFilter}
<div class="grid grid-cols-3 gap-3 pb-4 pr-1"> <div class="grid grid-cols-3 gap-3 pb-4 pr-1">
{#each filteredThemes as theme, index (theme.folder_name)} {#each filteredThemes as theme, index (theme.folder_name)}
{@const isActive = $active_custom_theme?.folder_name === theme.folder_name} {@const isActive =
<div $active_custom_theme?.folder_name === theme.folder_name}
class="group flex flex-col overflow-hidden rounded-xl border bg-theme-900/30 {isActive
? 'border-primary/60 ring-1 ring-primary/15'
: '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'}"
in:fly={{
duration: $reduceAnimations ? 0 : 260,
delay: $reduceAnimations ? 0 : Math.min(index * 35, 210),
y: 8,
opacity: 0,
}}
>
<div <div
class="relative h-36 w-full shrink-0 overflow-hidden bg-theme-950/60" class="group flex flex-col overflow-hidden rounded-xl border bg-theme-900/30 {isActive
? 'border-primary/60 ring-1 ring-primary/15'
: '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'}"
in:fly={{
duration: $reduceAnimations ? 0 : 260,
delay: $reduceAnimations ? 0 : Math.min(index * 35, 210),
y: 8,
opacity: 0,
}}
> >
<div <div
class="h-full w-full {$reduceAnimations class="relative h-36 w-full shrink-0 overflow-hidden bg-theme-950/60"
? ''
: 'transition-transform duration-500 ease-out group-hover:scale-[1.03]'} {theme.nsfw &&
!revealedNsfw.includes(theme.name)
? 'scale-105 blur-2xl'
: ''}"
> >
{#if theme.preview} <div
<img class="h-full w-full {$reduceAnimations
src={theme.preview} ? ''
alt="Preview of {theme.name}" : 'transition-transform duration-500 ease-out group-hover:scale-[1.03]'} {theme.nsfw &&
class="pointer-events-none h-full w-full object-cover object-center select-none" !revealedNsfw.includes(theme.name)
/> ? 'scale-105 blur-2xl'
{:else if themeBackgroundVideo(theme)} : ''}"
<video >
src={themeBackgroundVideo(theme)} {#if theme.preview}
class="pointer-events-none h-full w-full object-cover object-center" <img
autoplay src={theme.preview}
loop alt="Preview of {theme.name}"
muted class="pointer-events-none h-full w-full object-cover object-center select-none"
playsinline />
></video> {:else if themeBackgroundVideo(theme)}
{:else if theme.definition} <video
<div src={themeBackgroundVideo(theme)}
class="h-full w-full" class="pointer-events-none h-full w-full object-cover object-center"
style={themeV2ThumbnailStyle(theme.definition, theme.assets)} autoplay
></div> loop
{:else} muted
<img playsinline
src={DefaultThemePreview} ></video>
alt="Preview of {theme.name}" {:else if theme.definition}
class="pointer-events-none h-full w-full object-cover object-center select-none" <div
/> class="h-full w-full"
style={themeV2ThumbnailStyle(theme.definition, theme.assets)}
></div>
{:else}
<img
src={DefaultThemePreview}
alt="Preview of {theme.name}"
class="pointer-events-none h-full w-full object-cover object-center select-none"
/>
{/if}
</div>
<div
class="pointer-events-none absolute inset-0 bg-gradient-to-t from-theme-950/75 via-transparent to-transparent"
></div>
{#if isActive}
<Badge class="absolute top-1.5 left-1.5 text-[9px]">In use</Badge>
{/if}
{#if theme.name !== 'Default' && theme.folder_name !== 'default' && theme.apiVersion}
<Badge
variant="secondary"
class="absolute top-1.5 right-1.5 bg-black/50 px-1.5 py-0 text-[9px] backdrop-blur-sm"
>
API {theme.apiVersion.startsWith('2') ? '2.0' : '1.0'}
</Badge>
{/if}
{#if theme.nsfw && !revealedNsfw.includes(theme.name)}
<button
type="button"
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-1 bg-black/20 text-white transition hover:bg-black/30"
onclick={() => toggleNsfwPreview(theme.name)}
>
<ThemeIcon name="eye" set={iconSet} class="size-5" />
<span class="text-[9px] font-semibold uppercase tracking-wide">
Adult preview
</span>
<span class="text-[8px] text-white/70">Click to reveal</span>
</button>
{:else if theme.nsfw}
<button
type="button"
class="absolute bottom-1.5 left-1.5 z-10 flex items-center gap-1 rounded-full bg-black/60 px-1.5 py-0.5 text-[8px] font-medium text-white/80 backdrop-blur-sm hover:bg-black/75 hover:text-white"
onclick={() => toggleNsfwPreview(theme.name)}
>
<ThemeIcon name="eye-off" set={iconSet} class="size-3" />
Hide preview
</button>
{/if}
{#if theme.nsfw}
<Badge
variant="destructive"
class="absolute right-1.5 bottom-1.5 px-1.5 py-0 text-[9px]"
title="Adult content"
>
18+
</Badge>
{/if} {/if}
</div> </div>
<div <div class="flex flex-1 flex-col gap-1.5 p-2.5">
class="pointer-events-none absolute inset-0 bg-gradient-to-t from-theme-950/75 via-transparent to-transparent" <div class="flex items-start justify-between gap-1.5">
></div> <div class="min-w-0">
{#if isActive} <span
<Badge class="absolute top-1.5 left-1.5 text-[9px]">In use</Badge> class="block truncate text-[13px] leading-tight font-semibold"
{/if} >
{#if theme.name !== 'Default' && theme.folder_name !== 'default' && theme.apiVersion} {theme.name}
<Badge </span>
variant="secondary" <span
class="absolute top-1.5 right-1.5 bg-black/50 px-1.5 py-0 text-[9px] backdrop-blur-sm" class="mt-0.5 block truncate text-[10px] text-muted-foreground"
> >
API {theme.apiVersion.startsWith('2') ? '2.0' : '1.0'} by {theme.author}
</Badge> </span>
{/if} </div>
{#if theme.nsfw && !revealedNsfw.includes(theme.name)}
<button
type="button"
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-1 bg-black/20 text-white transition hover:bg-black/30"
onclick={() => toggleNsfwPreview(theme.name)}
>
<ThemeIcon name="eye" set={iconSet} class="size-5" />
<span class="text-[9px] font-semibold uppercase tracking-wide">
Adult preview
</span>
<span class="text-[8px] text-white/70">Click to reveal</span>
</button>
{:else if theme.nsfw}
<button
type="button"
class="absolute bottom-1.5 left-1.5 z-10 flex items-center gap-1 rounded-full bg-black/60 px-1.5 py-0.5 text-[8px] font-medium text-white/80 backdrop-blur-sm hover:bg-black/75 hover:text-white"
onclick={() => toggleNsfwPreview(theme.name)}
>
<ThemeIcon name="eye-off" set={iconSet} class="size-3" />
Hide preview
</button>
{/if}
{#if theme.nsfw}
<Badge
variant="destructive"
class="absolute right-1.5 bottom-1.5 px-1.5 py-0 text-[9px]"
title="Adult content"
>
18+
</Badge>
{/if}
</div>
<div class="flex flex-1 flex-col gap-1.5 p-2.5">
<div class="flex items-start justify-between gap-1.5">
<div class="min-w-0">
<span <span
class="block truncate text-[13px] leading-tight font-semibold" class="shrink-0 rounded-full bg-theme-800/50 px-1.5 py-0.5 text-[9px] whitespace-nowrap tabular-nums text-muted-foreground"
> >
{theme.name} {#if theme.updateAvailable && theme.availableVersion}
</span> v{theme.version} <span class="text-primary">→</span>
<span v{theme.availableVersion}
class="mt-0.5 block truncate text-[10px] text-muted-foreground" {:else}
> v{theme.version}
by {theme.author} {/if}
</span> </span>
</div> </div>
<span {#if theme.description}
class="shrink-0 rounded-full bg-theme-800/50 px-1.5 py-0.5 text-[9px] whitespace-nowrap tabular-nums text-muted-foreground" <p
> class="line-clamp-2 text-[10px] leading-snug text-muted-foreground"
{#if theme.updateAvailable && theme.availableVersion} >
v{theme.version} <span class="text-primary">→</span> {theme.description}
v{theme.availableVersion} </p>
{:else} {/if}
v{theme.version} <div class="mt-auto flex items-center gap-1 pt-0.5">
{/if} <Button
</span> class="relative h-8 min-w-0 flex-1 overflow-hidden px-2 text-xs {theme.status ===
</div> 'downloading' || theme.status === 'extracting'
{#if theme.description} ? 'disabled:bg-primary disabled:opacity-100'
<p : ''}"
class="line-clamp-2 text-[10px] leading-snug text-muted-foreground" size="sm"
> aria-busy={theme.status === 'downloading' ||
{theme.description} theme.status === 'extracting'}
</p> disabled={isActive ||
{/if} theme.status === 'downloading' ||
<div class="mt-auto flex items-center gap-1 pt-0.5"> theme.status === 'extracting' ||
<Button theme.status === 'deleting'}
class="relative h-8 min-w-0 flex-1 overflow-hidden px-2 text-xs {theme.status === onclick={() => requestThemeApply(theme)}
'downloading' || theme.status === 'extracting' >
? 'disabled:bg-primary disabled:opacity-100' {#if theme.status === 'downloading' || theme.status === 'extracting'}
: ''}"
size="sm"
aria-busy={theme.status === 'downloading' ||
theme.status === 'extracting'}
disabled={isActive ||
theme.status === 'downloading' ||
theme.status === 'extracting' ||
theme.status === 'deleting'}
onclick={() => requestThemeApply(theme)}
>
{#if theme.status === 'downloading' || theme.status === 'extracting'}
<span
class="absolute inset-y-0 left-0 bg-white/20 transition-[width] duration-300 ease-out"
style={`width: ${Math.min(100, Math.max(0, theme.progress * 100))}%`}
></span>
{#if !$reduceAnimations}
<span <span
class="theme-download-shimmer absolute inset-y-0 left-0 w-1/2 bg-gradient-to-r from-transparent via-white/25 to-transparent" class="absolute inset-y-0 left-0 bg-white/20 transition-[width] duration-300 ease-out"
style={`width: ${Math.min(100, Math.max(0, theme.progress * 100))}%`}
></span> ></span>
{#if !$reduceAnimations}
<span
class="theme-download-shimmer absolute inset-y-0 left-0 w-1/2 bg-gradient-to-r from-transparent via-white/25 to-transparent"
></span>
{/if}
{/if} {/if}
{/if} <span
<span class="relative z-10 flex items-center justify-center gap-2"> class="relative z-10 flex items-center justify-center gap-2"
{#if theme.status === 'extracting'} >
<ThemeIcon {#if theme.status === 'extracting'}
name="loader" <ThemeIcon
set={iconSet} name="loader"
class="size-4 animate-spin" set={iconSet}
class="size-4 animate-spin"
/>
{/if}
<TextMorph
text={themeButtonLabel(
theme,
$active_custom_theme?.folder_name
)}
duration={260}
ease="cubic-bezier(0.19, 1, 0.22, 1)"
disabled={$reduceAnimations}
class="whitespace-nowrap"
/> />
{/if} </span>
<TextMorph
text={themeButtonLabel(
theme,
$active_custom_theme?.folder_name
)}
duration={260}
ease="cubic-bezier(0.19, 1, 0.22, 1)"
disabled={$reduceAnimations}
class="whitespace-nowrap"
/>
</span>
</Button>
{#if theme.status === 'installed' && theme.definition}
<Button
variant="secondary"
size="icon"
class="size-7 shrink-0"
title="Edit theme"
onclick={() => openThemeEditor(theme)}
>
<ThemeIcon name="edit" set={iconSet} class="size-3.5" />
</Button> </Button>
{/if} {#if theme.status === 'installed' && theme.definition}
{#if theme.status === 'installed' && (theme.backups?.length ?? 0) > 0} <Button
<Button variant="secondary"
variant="secondary" size="icon"
size="icon" class="size-7 shrink-0"
class="size-7 shrink-0" title="Edit theme"
title="Restore previous version ({new Date( onclick={() => openThemeEditor(theme)}
theme.backups![0].createdAt >
).toLocaleDateString()})" <ThemeIcon name="edit" set={iconSet} class="size-3.5" />
onclick={() => requestThemeRestore(theme, theme.backups![0])} </Button>
> {/if}
<ThemeIcon name="history" set={iconSet} class="size-3.5" /> {#if theme.status === 'installed' && (theme.backups?.length ?? 0) > 0}
</Button> <Button
{/if} variant="secondary"
{#if theme.status === 'installed' && theme.updateAvailable} size="icon"
<Button class="size-7 shrink-0"
variant="secondary" title="Restore previous version ({new Date(
size="icon" theme.backups![0].createdAt
class="size-7 shrink-0" ).toLocaleDateString()})"
title={`Update theme${theme.availableVersion ? ` to v${theme.availableVersion}` : ''}`} onclick={() => requestThemeRestore(theme, theme.backups![0])}
onclick={() => updateInstalledTheme(theme)} >
> <ThemeIcon name="history" set={iconSet} class="size-3.5" />
<ThemeIcon name="download" set={iconSet} class="size-3.5" /> </Button>
</Button> {/if}
{/if} {#if theme.status === 'installed' && theme.updateAvailable}
{#if (theme.status === 'installed' || theme.status === 'deleting') && !isActive && theme.name !== 'Default'} <Button
<Button variant="secondary"
variant="destructive" size="icon"
size="icon" class="size-7 shrink-0"
class="size-7 shrink-0" title={`Update theme${theme.availableVersion ? ` to v${theme.availableVersion}` : ''}`}
title="Uninstall theme" onclick={() => updateInstalledTheme(theme)}
disabled={theme.status !== 'installed'} >
onclick={async () => { <ThemeIcon name="download" set={iconSet} class="size-3.5" />
try { </Button>
const deleteResult = await deleteTheme(theme); {/if}
if (deleteResult) { {#if (theme.status === 'installed' || theme.status === 'deleting') && !isActive && theme.name !== 'Default'}
sileo.success({ <Button
title: 'Yaay!', variant="destructive"
description: 'Theme uninstalled successfully!', size="icon"
}); class="size-7 shrink-0"
} else { title="Uninstall theme"
disabled={theme.status !== 'installed'}
onclick={async () => {
try {
const deleteResult = await deleteTheme(theme);
if (deleteResult) {
sileo.success({
title: 'Yaay!',
description: 'Theme uninstalled successfully!',
});
} else {
sileo.error({
title: 'Uhhm..',
description: 'Failed to uninstall theme.',
});
}
} catch (err) {
console.log(err);
sileo.error({ sileo.error({
title: 'Uhhm..', title: 'Uhhm..',
description: 'Failed to uninstall theme.', description: 'Failed to uninstall theme.',
}); });
} }
} catch (err) { }}
console.log(err); >
sileo.error({ {#if theme.status === 'deleting'}
title: 'Uhhm..', <ThemeIcon
description: 'Failed to uninstall theme.', name="loader"
}); set={iconSet}
} class="size-3.5 animate-spin"
}} />
> {:else}
{#if theme.status === 'deleting'} <ThemeIcon name="trash" set={iconSet} class="size-3.5" />
<ThemeIcon {/if}
name="loader" </Button>
set={iconSet} {/if}
class="size-3.5 animate-spin" {#if $currentUser && ownedWorkshopLoaded && !isOwnInstalledTheme(theme) && theme.status === 'installed' && theme.name !== 'Default' && theme.folder_name !== 'default'}
/> <Button
{:else} variant="secondary"
<ThemeIcon name="trash" set={iconSet} class="size-3.5" /> size="icon"
{/if} class="size-7 shrink-0 text-muted-foreground hover:bg-amber-500/15 hover:text-amber-300"
</Button> title="Report theme"
{/if} aria-label={`Report ${theme.name}`}
{#if $currentUser && ownedWorkshopLoaded && !isOwnInstalledTheme(theme) && theme.status === 'installed' && theme.name !== 'Default' && theme.folder_name !== 'default'} disabled={installedReportLoading}
<Button onclick={() => void openInstalledReport(theme)}
variant="secondary" >
size="icon" <Flag class="size-3.5" />
class="size-7 shrink-0 text-muted-foreground hover:bg-amber-500/15 hover:text-amber-300" </Button>
title="Report theme" {/if}
aria-label={`Report ${theme.name}`} </div>
disabled={installedReportLoading}
onclick={() => void openInstalledReport(theme)}
>
<Flag class="size-3.5" />
</Button>
{/if}
</div> </div>
</div> </div>
</div> {/each}
{/each} </div>
</div> {/key}
{/key} {:else}
{:else} <div
<div class="flex h-full min-h-[320px] flex-col items-center justify-center gap-2 px-6 text-center text-muted-foreground"
class="flex h-full min-h-[320px] flex-col items-center justify-center gap-2 px-6 text-center text-muted-foreground" >
> <div class="rounded-full bg-theme-900/60 p-3">
<div class="rounded-full bg-theme-900/60 p-3"> <ThemeIcon name="search" set={iconSet} class="size-5 opacity-60" />
<ThemeIcon name="search" set={iconSet} class="size-5 opacity-60" /> </div>
</div> <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'}
{:else if themeFilter === 'updates'} Everything is up to date
Everything is up to date {:else}
{:else} No themes found
No themes found {/if}
</p>
<p class="max-w-xs text-xs leading-relaxed">
{#if themeSearch}
Try a different name, author or API version.
{:else if themeFilter === 'updates'}
You are running the latest version of every installed theme.
{:else}
Discover themes in the Community Workshop or create a new API 2.0 theme.
{/if}
</p>
{#if themeSearch || themeFilter !== 'all'}
<Button
class="mt-1"
variant="secondary"
size="sm"
onclick={() => {
themeSearch = '';
themeFilter = 'all';
}}
>
Clear filters
</Button>
{/if} {/if}
</p> </div>
<p class="max-w-xs text-xs leading-relaxed"> {/if}
{#if themeSearch} </ScrollContainer>
Try a different name, author or API version. {/if}
{:else if themeFilter === 'updates'} </div>
You are running the latest version of every installed theme.
{:else}
Discover themes in the Community Workshop or create a new API 2.0 theme.
{/if}
</p>
{#if themeSearch || themeFilter !== 'all'}
<Button
class="mt-1"
variant="secondary"
size="sm"
onclick={() => {
themeSearch = '';
themeFilter = 'all';
}}
>
Clear filters
</Button>
{/if}
</div>
{/if}
</ScrollContainer>
{/if}
</div>
</div> </div>
</div> </div>
</div> </div>