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.
This commit is contained in:
HorizonCode committed 2026-11-03 23:08:35 +01:00
1 parent 5613c3be25
commit 47c022642f
2 files changed
+435 -100

No files matched your search

@@ -231,6 +231,7 @@
let actionError = $state<string | null>(null); let actionError = $state<string | null>(null);
let actionBusy = $state(false); let actionBusy = $state(false);
let actionDialogContent = $state<HTMLElement | null>(null); let actionDialogContent = $state<HTMLElement | null>(null);
let infoTheme = $state<CommunityTheme | null>(null);
const REPORT_CATEGORIES: Array<{ id: ThemeReportCategory; label: string }> = [ const REPORT_CATEGORIES: Array<{ id: ThemeReportCategory; label: string }> = [
{ id: 'spam', label: 'Spam / advertising' }, { id: 'spam', label: 'Spam / advertising' },
@@ -426,6 +427,29 @@
actionError = null; 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() { async function confirmPendingAction() {
const action = pendingAction; const action = pendingAction;
if (!action || actionBusy) return; 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" : '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"
> >
<!-- 16:9 Thumbnail --> <!-- 16:9 Thumbnail -->
<div class="relative aspect-video w-full shrink-0 overflow-hidden bg-theme-950/70"> <div
class="relative aspect-video w-full shrink-0 cursor-zoom-in overflow-hidden bg-theme-950/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
role="button"
tabindex="0"
aria-label={`View ${theme.name} details`}
onclick={() => openThemeInfo(theme)}
onkeydown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
openThemeInfo(theme);
}}
>
<div
class="pointer-events-none absolute inset-0 z-[5] flex items-center justify-center opacity-0 transition-opacity duration-200 group-hover:opacity-100"
>
<span
class="rounded-full border border-white/15 bg-theme-950/80 px-3 py-1.5 text-[10px] font-medium text-white shadow-lg backdrop-blur-sm"
>
View details
</span>
</div>
<img <img
src={theme.previewUrl} src={theme.previewUrl}
alt={theme.name} alt={theme.name}
@@ -2257,11 +2301,14 @@
<div class="flex flex-1 flex-col justify-between gap-2 p-3"> <div class="flex flex-1 flex-col justify-between gap-2 p-3">
<div class="min-w-0"> <div class="min-w-0">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<h3 <button
class="truncate text-[13px] font-bold text-white group-hover:text-primary transition-colors" type="button"
class="min-w-0 truncate rounded text-left text-[13px] font-bold text-white transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/60"
title={`View ${theme.name} details`}
onclick={() => openThemeInfo(theme)}
> >
{theme.name} {theme.name}
</h3> </button>
<!-- Interactive Like Button --> <!-- Interactive Like Button -->
{#if isDeleted} {#if isDeleted}
@@ -3993,6 +4040,246 @@
</div> </div>
</div> </div>
{/if} {/if}
<Dialog.Root
open={infoTheme !== null}
onOpenChange={(open) => {
if (!open) closeThemeInfo();
}}
>
<Dialog.Content
class="flex max-h-[calc(100vh-2rem)] w-[calc(100%_-_2rem)] max-w-5xl flex-col gap-0 overflow-hidden rounded-xl border-theme-800 bg-theme-950 p-0 text-white shadow-2xl sm:rounded-xl"
>
{#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}
<div class="flex items-start justify-between gap-3 border-b border-theme-800/70 p-4">
<div class="min-w-0">
<Dialog.Title class="truncate text-base font-semibold text-white">
{themeInfo.name}
</Dialog.Title>
<Dialog.Description
class="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-[11px] text-muted-foreground"
>
<span>by <span class="text-white/80">{themeInfo.author.name}</span></span>
<span>·</span>
<span>v{themeInfo.version}</span>
<span>·</span>
<span
>{themeInfo.apiVersion === '1.0'
? 'Legacy v1'
: `API ${themeInfo.apiVersion}`}</span
>
{#if infoActive}
<span>·</span>
<span class="font-medium text-primary">In use</span>
{/if}
</Dialog.Description>
</div>
<Button
variant="ghost"
size="icon"
class="size-7 shrink-0 text-muted-foreground hover:text-white"
onclick={closeThemeInfo}
aria-label="Close theme details"
>
<X class="size-4" />
</Button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
<div class="grid md:grid-cols-[minmax(0,1.6fr)_minmax(280px,0.9fr)]">
<div class="space-y-3 bg-black/25 p-3 sm:p-4">
<div class="overflow-hidden rounded-xl border border-theme-800 bg-theme-950">
<img
src={themeInfo.previewUrl}
alt={`${themeInfo.name} preview`}
class="aspect-video w-full object-cover object-center"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).src = DefaultThemePreview;
}}
/>
</div>
<div
class="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[10px] text-muted-foreground"
>
<span class="inline-flex items-center gap-1">
<Download class="size-3" />{themeInfo.downloadCount} downloads
</span>
<span class="inline-flex items-center gap-1">
<Heart class="size-3" />{themeInfo.likeCount} likes
</span>
<span>{formatBytes(themeInfo.fileSize)}</span>
<span>Updated {formatModerationDate(themeInfo.updatedAt)}</span>
</div>
<p class="text-[11px] leading-relaxed text-white/80">
{themeInfo.description || 'No description provided.'}
</p>
</div>
<div
class="flex flex-col gap-4 border-t border-theme-800/70 p-4 md:border-t-0 md:border-l"
>
<div>
<p class="text-[10px] font-semibold tracking-wide text-white/60 uppercase">Tags</p>
{#if themeInfo.tags.length > 0}
<div class="mt-2 flex flex-wrap gap-1.5">
{#each themeInfo.tags as tag (tag)}
<button
type="button"
class="rounded-full border border-theme-700 bg-theme-900 px-2 py-0.5 text-[10px] text-muted-foreground transition-colors hover:border-primary/40 hover:text-white"
onclick={() => openThemeInfoTag(tag)}
>
#{tag}
</button>
{/each}
</div>
{:else}
<p class="mt-1.5 text-[10px] text-muted-foreground">No tags.</p>
{/if}
</div>
{#if themeInfo.reviewStatus !== 'approved'}
<div
class="rounded-lg border border-amber-500/25 bg-amber-500/5 p-2.5 text-[10px] leading-relaxed"
>
{#if themeInfo.reviewStatus === 'pending'}
<p class="text-amber-200">
<LoaderCircle class="mr-1 inline size-3 align-[-2px]" /> Awaiting staff review.
</p>
{:else}
<p class="text-red-200">
<X class="mr-1 inline size-3 align-[-2px]" /> Not approved.
</p>
{#if themeInfo.reviewNote}
<p class="mt-1 text-muted-foreground">{themeInfo.reviewNote}</p>
{/if}
{/if}
</div>
{/if}
{#if infoUnavailable}
<div
class="rounded-lg border border-red-900/40 bg-red-950/15 p-2.5 text-[10px] leading-relaxed"
>
{#if themeInfo.isQuarantined}
<p class="text-red-200">
Taken down: {themeInfo.quarantineReason ?? 'No reason provided.'}
</p>
{/if}
{#if themeInfo.isHidden}
<p class="text-red-200">
Hidden: {themeInfo.hiddenReason ?? 'No reason provided.'}
</p>
{/if}
</div>
{/if}
{#if themeInfo.forkedFrom}
{@const forkedFrom = themeInfo.forkedFrom}
<button
type="button"
class="flex items-start gap-1.5 text-left text-[10px] text-muted-foreground transition-colors hover:text-white"
onclick={() => {
closeThemeInfo();
void handleOpenForkSource(themeInfo);
}}
>
<GitFork class="mt-0.5 size-3 shrink-0" />
<span class="truncate">
Forked from {forkedFrom.name} by {forkedFrom.authorName}
</span>
</button>
{/if}
<div class="mt-auto space-y-2 pt-2">
{#if themeInfo.deletedAt}
<p
class="rounded-lg border border-red-900/40 bg-red-950/15 p-2.5 text-[10px] text-red-200"
>
This theme is in your trash. Restore it from the Trash view.
</p>
{:else if infoUnavailable}
<Button class="w-full gap-1.5" variant="secondary" disabled>
<EyeOff class="size-3.5" /> Currently unavailable
</Button>
{:else if infoDownloading}
<div class="space-y-1.5 rounded-lg border border-primary/30 bg-primary/10 p-2.5">
<div class="flex items-center justify-between text-[10px]">
<span class="text-primary">Downloading...</span>
<span class="text-primary tabular-nums">{infoPercent}%</span>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-theme-800">
<div
class="h-full bg-primary transition-all duration-200"
style={`width: ${infoPercent}%`}
></div>
</div>
</div>
{:else if infoOwned && infoInstalled && !infoLegacy}
<Button class="w-full gap-1.5" onclick={() => editFromThemeInfo(themeInfo)}>
<Pencil class="size-3.5" /> Edit theme
</Button>
{:else if infoActive}
<Button class="w-full gap-1.5" disabled>
<Check class="size-3.5" /> Theme in use
</Button>
{:else if infoInstalled && infoHasUpdate}
<Button
class="w-full gap-1.5"
onclick={() => void handleDownloadTheme(themeInfo)}
>
<Download class="size-3.5" /> Update to v{themeInfo.version}
</Button>
{:else if infoInstalled}
<Button class="w-full gap-1.5" onclick={() => applyFromThemeInfo(themeInfo)}>
<Check class="size-3.5" /> Apply theme
</Button>
{:else}
<Button
class="w-full gap-1.5"
onclick={() => void handleDownloadTheme(themeInfo)}
>
<Download class="size-3.5" /> Download & install
</Button>
{/if}
{#if !infoOwned && $currentUser && !infoUnavailable && !themeInfo.deletedAt}
<Button
variant="ghost"
size="sm"
class="w-full gap-1.5 text-[11px] text-muted-foreground hover:text-white"
onclick={() => {
closeThemeInfo();
openReportDialog(themeInfo);
}}
>
<Flag class="size-3.5" /> Report theme
</Button>
{/if}
</div>
</div>
</div>
</div>
{/if}
</Dialog.Content>
</Dialog.Root>
<Dialog.Root <Dialog.Root
open={pendingAction !== null} open={pendingAction !== null}
onOpenChange={(open) => { onOpenChange={(open) => {
+144 -96
View File
@@ -163,6 +163,7 @@
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Clock, Clock,
Ellipsis,
ExternalLink, ExternalLink,
Flag, Flag,
Globe, Globe,
@@ -505,6 +506,29 @@
await config.value<string>('theme').set(theme.name); await config.value<string>('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 updateInstalledTheme = async (theme: Theme) => {
const wasActive = $active_custom_theme?.folder_name === theme.folder_name; const wasActive = $active_custom_theme?.folder_name === theme.folder_name;
const updateResult = await downloadTheme(theme, true); const updateResult = await downloadTheme(theme, true);
@@ -3911,6 +3935,28 @@
{#each filteredThemes as theme, index (theme.folder_name)} {#each filteredThemes as theme, index (theme.folder_name)}
{@const isActive = {@const isActive =
$active_custom_theme?.folder_name === theme.folder_name} $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}
<div <div
class="group flex flex-col overflow-hidden rounded-xl border bg-theme-900/30 {isActive class="group flex flex-col overflow-hidden rounded-xl border bg-theme-900/30 {isActive
? 'border-primary/60 ring-1 ring-primary/15' ? 'border-primary/60 ring-1 ring-primary/15'
@@ -3925,7 +3971,7 @@
}} }}
> >
<div <div
class="relative h-36 w-full shrink-0 overflow-hidden bg-theme-950/60" class="relative aspect-video w-full shrink-0 overflow-hidden bg-theme-950/60"
> >
<div <div
class="h-full w-full {$reduceAnimations class="h-full w-full {$reduceAnimations
@@ -4034,13 +4080,11 @@
{/if} {/if}
</span> </span>
</div> </div>
{#if theme.description} <p
<p class="min-h-8 line-clamp-2 text-[10px] leading-snug text-muted-foreground"
class="line-clamp-2 text-[10px] leading-snug text-muted-foreground" >
> {theme.description}
{theme.description} </p>
</p>
{/if}
<div class="mt-auto flex items-center gap-1 pt-0.5"> <div class="mt-auto flex items-center gap-1 pt-0.5">
<Button <Button
class="relative h-8 min-w-0 flex-1 overflow-hidden px-2 text-xs {theme.status === class="relative h-8 min-w-0 flex-1 overflow-hidden px-2 text-xs {theme.status ===
@@ -4089,94 +4133,98 @@
/> />
</span> </span>
</Button> </Button>
{#if theme.status === 'installed' && theme.definition} {#if hasThemeMenu}
<Button <DropdownMenu.Root>
variant="secondary" <DropdownMenu.Trigger>
size="icon" {#snippet child({ props })}
class="size-7 shrink-0" <Button
title="Edit theme" {...props}
onclick={() => openThemeEditor(theme)} variant="secondary"
> size="icon"
<ThemeIcon name="edit" set={iconSet} class="size-3.5" /> class="size-7 shrink-0 text-muted-foreground hover:text-white"
</Button> title="More theme actions"
{/if} aria-label={`More actions for ${theme.name}`}
{#if theme.status === 'installed' && (theme.backups?.length ?? 0) > 0} >
<Button <Ellipsis class="size-3.5" />
variant="secondary" </Button>
size="icon" {/snippet}
class="size-7 shrink-0" </DropdownMenu.Trigger>
title="Restore previous version ({new Date( <DropdownMenu.Content
theme.backups![0].createdAt align="end"
).toLocaleDateString()})" class="w-48 border-theme-700 bg-theme-950 text-theme-50"
onclick={() => requestThemeRestore(theme, theme.backups![0])} >
> {#if canEditTheme}
<ThemeIcon name="history" set={iconSet} class="size-3.5" /> <DropdownMenu.Item
</Button> class="gap-2 text-[11px]"
{/if} onclick={() => openThemeEditor(theme)}
{#if theme.status === 'installed' && theme.updateAvailable} >
<Button <ThemeIcon name="edit" set={iconSet} class="size-3.5" />
variant="secondary" Edit theme
size="icon" </DropdownMenu.Item>
class="size-7 shrink-0" {/if}
title={`Update theme${theme.availableVersion ? ` to v${theme.availableVersion}` : ''}`} {#if canRestoreTheme}
onclick={() => updateInstalledTheme(theme)} <DropdownMenu.Item
> class="gap-2 text-[11px]"
<ThemeIcon name="download" set={iconSet} class="size-3.5" /> onclick={() =>
</Button> requestThemeRestore(theme, theme.backups![0])}
{/if} >
{#if (theme.status === 'installed' || theme.status === 'deleting') && !isActive && theme.name !== 'Default'} <ThemeIcon
<Button name="history"
variant="destructive" set={iconSet}
size="icon" class="size-3.5"
class="size-7 shrink-0" />
title="Uninstall theme" Restore previous version
disabled={theme.status !== 'installed'} </DropdownMenu.Item>
onclick={async () => { {/if}
try { {#if canUpdateTheme}
const deleteResult = await deleteTheme(theme); <DropdownMenu.Item
if (deleteResult) { class="gap-2 text-[11px]"
sileo.success({ onclick={() => updateInstalledTheme(theme)}
title: 'Yaay!', >
description: 'Theme uninstalled successfully!', <ThemeIcon
}); name="download"
} else { set={iconSet}
sileo.error({ class="size-3.5"
title: 'Uhhm..', />
description: 'Failed to uninstall theme.', Update theme
}); </DropdownMenu.Item>
} {/if}
} catch (err) { {#if canReportTheme}
console.log(err); <DropdownMenu.Item
sileo.error({ class="gap-2 text-[11px]"
title: 'Uhhm..', disabled={installedReportLoading}
description: 'Failed to uninstall theme.', onclick={() => void openInstalledReport(theme)}
}); >
} <Flag class="size-3.5" />
}} Report theme
> </DropdownMenu.Item>
{#if theme.status === 'deleting'} {/if}
<ThemeIcon {#if canUninstallTheme}
name="loader" <DropdownMenu.Separator class="my-1 bg-theme-800" />
set={iconSet} <DropdownMenu.Item
class="size-3.5 animate-spin" class="gap-2 text-[11px] text-red-300 data-[highlighted]:bg-red-500/15 data-[highlighted]:text-red-200"
/> disabled={theme.status !== 'installed'}
{:else} onclick={() => void uninstallInstalledTheme(theme)}
<ThemeIcon name="trash" set={iconSet} class="size-3.5" /> >
{/if} {#if theme.status === 'deleting'}
</Button> <ThemeIcon
{/if} name="loader"
{#if $currentUser && ownedWorkshopLoaded && !isOwnInstalledTheme(theme) && theme.status === 'installed' && theme.name !== 'Default' && theme.folder_name !== 'default'} set={iconSet}
<Button class="size-3.5 animate-spin"
variant="secondary" />
size="icon" Uninstalling...
class="size-7 shrink-0 text-muted-foreground hover:bg-amber-500/15 hover:text-amber-300" {:else}
title="Report theme" <ThemeIcon
aria-label={`Report ${theme.name}`} name="trash"
disabled={installedReportLoading} set={iconSet}
onclick={() => void openInstalledReport(theme)} class="size-3.5"
> />
<Flag class="size-3.5" /> Uninstall theme
</Button> {/if}
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if} {/if}
</div> </div>
</div> </div>