From 47c022642f2c167d6637b26da9f6c2e75b097397 Mon Sep 17 00:00:00 2001 From: HorizonCode Date: Tue, 3 Nov 2026 23:08:35 +0100 Subject: [PATCH] feat(themes): polish installed cards and add workshop theme details Installed themes now use a 16:9 preview with stable card spacing and a single overflow menu for edit, restore, update, report and uninstall actions. Community theme previews and titles open a large detail dialog with a full-size preview, metadata, tags, review status and the relevant install action. --- .../theme/CommunityWorkshopView.svelte | 295 +++++++++++++++++- src/screens/Launch.svelte | 240 ++++++++------ 2 files changed, 435 insertions(+), 100 deletions(-) diff --git a/src/lib/components/theme/CommunityWorkshopView.svelte b/src/lib/components/theme/CommunityWorkshopView.svelte index 8b1cb97..08a3d15 100644 --- a/src/lib/components/theme/CommunityWorkshopView.svelte +++ b/src/lib/components/theme/CommunityWorkshopView.svelte @@ -231,6 +231,7 @@ let actionError = $state(null); let actionBusy = $state(false); let actionDialogContent = $state(null); + let infoTheme = $state(null); const REPORT_CATEGORIES: Array<{ id: ThemeReportCategory; label: string }> = [ { id: 'spam', label: 'Spam / advertising' }, @@ -426,6 +427,29 @@ actionError = null; } + function openThemeInfo(theme: CommunityTheme) { + infoTheme = theme; + } + + function closeThemeInfo() { + infoTheme = null; + } + + function openThemeInfoTag(tag: string) { + closeThemeInfo(); + handleTagClick(tag); + } + + function editFromThemeInfo(theme: CommunityTheme) { + closeThemeInfo(); + void handleEditTheme(theme); + } + + function applyFromThemeInfo(theme: CommunityTheme) { + closeThemeInfo(); + void handleApplyExisting(theme); + } + async function confirmPendingAction() { const action = pendingAction; if (!action || actionBusy) return; @@ -2055,7 +2079,27 @@ : '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" > -
+
openThemeInfo(theme)} + onkeydown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + openThemeInfo(theme); + }} + > +
+ + View details + +
{theme.name}
-

openThemeInfo(theme)} > {theme.name} -

+ {#if isDeleted} @@ -3993,6 +4040,246 @@
{/if} + { + if (!open) closeThemeInfo(); + }} + > + + {#if infoTheme} + {@const themeInfo = infoTheme} + {@const infoLocal = + localThemeMap.get(themeInfo.id.toLowerCase()) ?? + localThemeMap.get(themeInfo.name.toLowerCase())} + {@const infoInstalled = Boolean(infoLocal)} + {@const infoActive = Boolean( + infoLocal && $active_custom_theme?.folder_name === infoLocal.folder_name + )} + {@const infoDownloading = activeThemeDownloads.has(themeInfo.id)} + {@const infoPercent = themeDownloadProgress.get(themeInfo.id) ?? 0} + {@const infoOwned = isOwnedByCurrentUser(themeInfo)} + {@const infoLegacy = + themeInfo.apiVersion === '1.0' || Boolean(themeInfo.tags?.includes('legacy'))} + {@const infoHasUpdate = Boolean( + !themeInfo.deletedAt && infoLocal && infoLocal.version !== themeInfo.version + )} + {@const infoUnavailable = themeInfo.isHidden || themeInfo.isQuarantined} + +
+
+ + {themeInfo.name} + + + by {themeInfo.author.name} + · + v{themeInfo.version} + · + {themeInfo.apiVersion === '1.0' + ? 'Legacy v1' + : `API ${themeInfo.apiVersion}`} + {#if infoActive} + · + In use + {/if} + +
+ +
+ +
+
+
+
+ {`${themeInfo.name} { + (e.currentTarget as HTMLImageElement).src = DefaultThemePreview; + }} + /> +
+
+ + {themeInfo.downloadCount} downloads + + + {themeInfo.likeCount} likes + + {formatBytes(themeInfo.fileSize)} + Updated {formatModerationDate(themeInfo.updatedAt)} +
+

+ {themeInfo.description || 'No description provided.'} +

+
+ +
+
+

Tags

+ {#if themeInfo.tags.length > 0} +
+ {#each themeInfo.tags as tag (tag)} + + {/each} +
+ {:else} +

No tags.

+ {/if} +
+ + {#if themeInfo.reviewStatus !== 'approved'} +
+ {#if themeInfo.reviewStatus === 'pending'} +

+ Awaiting staff review. +

+ {:else} +

+ Not approved. +

+ {#if themeInfo.reviewNote} +

{themeInfo.reviewNote}

+ {/if} + {/if} +
+ {/if} + + {#if infoUnavailable} +
+ {#if themeInfo.isQuarantined} +

+ Taken down: {themeInfo.quarantineReason ?? 'No reason provided.'} +

+ {/if} + {#if themeInfo.isHidden} +

+ Hidden: {themeInfo.hiddenReason ?? 'No reason provided.'} +

+ {/if} +
+ {/if} + + {#if themeInfo.forkedFrom} + {@const forkedFrom = themeInfo.forkedFrom} + + {/if} + +
+ {#if themeInfo.deletedAt} +

+ This theme is in your trash. Restore it from the Trash view. +

+ {:else if infoUnavailable} + + {:else if infoDownloading} +
+
+ Downloading... + {infoPercent}% +
+
+
+
+
+ {:else if infoOwned && infoInstalled && !infoLegacy} + + {:else if infoActive} + + {:else if infoInstalled && infoHasUpdate} + + {:else if infoInstalled} + + {:else} + + {/if} + + {#if !infoOwned && $currentUser && !infoUnavailable && !themeInfo.deletedAt} + + {/if} +
+
+
+
+ {/if} +
+
+ { diff --git a/src/screens/Launch.svelte b/src/screens/Launch.svelte index d7b5597..8924cec 100644 --- a/src/screens/Launch.svelte +++ b/src/screens/Launch.svelte @@ -163,6 +163,7 @@ ChevronLeft, ChevronRight, Clock, + Ellipsis, ExternalLink, Flag, Globe, @@ -505,6 +506,29 @@ await config.value('theme').set(theme.name); }; + const uninstallInstalledTheme = async (theme: Theme) => { + try { + const deleteResult = await deleteTheme(theme); + if (deleteResult) { + sileo.success({ + title: 'Theme uninstalled', + description: `"${theme.name}" was removed from your library.`, + }); + } else { + sileo.error({ + title: 'Could not uninstall theme', + description: 'The theme files could not be removed.', + }); + } + } catch (err) { + console.error(err); + sileo.error({ + title: 'Could not uninstall theme', + description: err instanceof Error ? err.message : String(err), + }); + } + }; + const updateInstalledTheme = async (theme: Theme) => { const wasActive = $active_custom_theme?.folder_name === theme.folder_name; const updateResult = await downloadTheme(theme, true); @@ -3911,6 +3935,28 @@ {#each filteredThemes as theme, index (theme.folder_name)} {@const isActive = $active_custom_theme?.folder_name === theme.folder_name} + {@const canEditTheme = + theme.status === 'installed' && Boolean(theme.definition)} + {@const canRestoreTheme = + theme.status === 'installed' && (theme.backups?.length ?? 0) > 0} + {@const canUpdateTheme = + theme.status === 'installed' && theme.updateAvailable} + {@const canReportTheme = + Boolean($currentUser && ownedWorkshopLoaded) && + !isOwnInstalledTheme(theme) && + theme.status === 'installed' && + theme.name !== 'Default' && + theme.folder_name !== 'default'} + {@const canUninstallTheme = + (theme.status === 'installed' || theme.status === 'deleting') && + !isActive && + theme.name !== 'Default'} + {@const hasThemeMenu = + canEditTheme || + canRestoreTheme || + canUpdateTheme || + canReportTheme || + canUninstallTheme}
- {theme.description} -

- {/if} +

+ {theme.description} +

- {/if} - {#if theme.status === 'installed' && (theme.backups?.length ?? 0) > 0} - - {/if} - {#if theme.status === 'installed' && theme.updateAvailable} - - {/if} - {#if (theme.status === 'installed' || theme.status === 'deleting') && !isActive && theme.name !== 'Default'} - - {/if} - {#if $currentUser && ownedWorkshopLoaded && !isOwnInstalledTheme(theme) && theme.status === 'installed' && theme.name !== 'Default' && theme.folder_name !== 'default'} - + {#if hasThemeMenu} + + + {#snippet child({ props })} + + {/snippet} + + + {#if canEditTheme} + openThemeEditor(theme)} + > + + Edit theme + + {/if} + {#if canRestoreTheme} + + requestThemeRestore(theme, theme.backups![0])} + > + + Restore previous version + + {/if} + {#if canUpdateTheme} + updateInstalledTheme(theme)} + > + + Update theme + + {/if} + {#if canReportTheme} + void openInstalledReport(theme)} + > + + Report theme + + {/if} + {#if canUninstallTheme} + + void uninstallInstalledTheme(theme)} + > + {#if theme.status === 'deleting'} + + Uninstalling... + {:else} + + Uninstall theme + {/if} + + {/if} + + {/if}