feat(workshop): download multiple selected themes in one batch

Only one theme could be installed at a time because a single download id
guarded every card, so users had to repeat the flow per theme. A selection
mode now adds checkboxes and a batch bar that installs the chosen themes in
sequence, with per-theme progress and a summary of failures.
This commit is contained in:
HorizonCode committed 2026-11-03 18:43:53 +01:00
1 parent fc128b7178
commit 6b1ed5aa1a
1 file changed
+227 -4
@@ -119,6 +119,14 @@
let downloadingId = $state<string | null>(null);
let downloadProgress = $state(0);
let selectionMode = $state(false);
let selectedThemeIds = $state<string[]>([]);
let bulkDownload = $state<{
current: number;
total: number;
name: string;
progress: number;
} | null>(null);
let forkingId = $state<string | null>(null);
let deletingId = $state<string | null>(null);
let moderatingId = $state<string | null>(null);
@@ -235,6 +243,15 @@
return map;
});
function findInstalledTheme(theme: CommunityTheme): Theme | undefined {
return localThemeMap.get(theme.id.toLowerCase()) || localThemeMap.get(theme.name.toLowerCase());
}
const selectedThemes = $derived(themes.filter((theme) => selectedThemeIds.includes(theme.id)));
const downloadableSelectedThemes = $derived(
selectedThemes.filter((theme) => !theme.deletedAt && !findInstalledTheme(theme))
);
const tagOptions = $derived.by(() => {
const counts = new SvelteMap<string, number>();
@@ -315,6 +332,7 @@
themes = [...themes, ...res.items.filter((theme) => !knownIds.has(theme.id))];
} else {
themes = res.items;
selectedThemeIds = [];
}
totalThemes = res.total;
page = res.page;
@@ -354,6 +372,8 @@
selectedView = view;
selectedTag = 'all';
showHiddenOnly = false;
selectionMode = false;
selectedThemeIds = [];
void loadWorkshopThemes();
}
@@ -406,7 +426,7 @@
}
async function handleDownloadAndApply(theme: CommunityTheme) {
if (downloadingId || theme.deletedAt) return;
if (downloadingId || bulkDownload || theme.deletedAt) return;
downloadingId = theme.id;
downloadProgress = 10;
@@ -445,6 +465,102 @@
}
}
function toggleSelectionMode() {
if (bulkDownload) return;
selectionMode = !selectionMode;
if (!selectionMode) selectedThemeIds = [];
}
function toggleThemeSelection(themeId: string) {
selectedThemeIds = selectedThemeIds.includes(themeId)
? selectedThemeIds.filter((id) => id !== themeId)
: [...selectedThemeIds, themeId];
}
function clearThemeSelection() {
selectedThemeIds = [];
}
function selectAllDownloadableThemes() {
selectedThemeIds = themes
.filter((theme) => !theme.deletedAt && !findInstalledTheme(theme))
.map((theme) => theme.id);
}
async function handleBulkDownload() {
if (bulkDownload || downloadingId) return;
const selected = themes.filter(
(theme) => selectedThemeIds.includes(theme.id) && !theme.deletedAt
);
const installable = selected.filter((theme) => !findInstalledTheme(theme));
const skipped = selected.length - installable.length;
if (installable.length === 0) {
sileo.info({
title: 'Nothing to download',
description:
selected.length === 0
? 'Select at least one theme first.'
: 'The selected themes are already installed.',
});
return;
}
// Installs run one after another because the download/extract progress events are global,
// so concurrent installs would report each other's progress.
const failures: string[] = [];
let installed = 0;
try {
for (let index = 0; index < installable.length; index += 1) {
const theme = installable[index];
bulkDownload = {
current: index + 1,
total: installable.length,
name: theme.name,
progress: 5,
};
try {
await downloadAndInstallCommunityTheme(theme, (_status, progress) => {
if (!bulkDownload) return;
bulkDownload = { ...bulkDownload, progress };
});
installed += 1;
} catch (err) {
console.error(`[Workshop] Could not download "${theme.name}":`, err);
failures.push(theme.name);
}
}
} finally {
bulkDownload = null;
}
if (installed > 0) {
sileo.success({
title: installed === 1 ? 'Theme installed' : `${installed} themes installed`,
description:
skipped > 0
? `${skipped} already-installed theme${skipped === 1 ? ' was' : 's were'} skipped.`
: 'Open the library to apply them.',
});
}
if (failures.length > 0) {
sileo.error({
title:
failures.length === 1
? 'Could not install 1 theme'
: `Could not install ${failures.length} themes`,
description: failures.join(', '),
});
}
selectedThemeIds = [];
selectionMode = false;
}
async function handleApplyExisting(theme: CommunityTheme) {
if (theme.deletedAt) return;
const installed =
@@ -462,7 +578,8 @@
}
async function handleEditTheme(theme: CommunityTheme) {
if (theme.deletedAt || downloadingId || forkingId || deletingId || moderatingId) return;
if (theme.deletedAt || downloadingId || bulkDownload || forkingId || deletingId || moderatingId)
return;
let installed =
localThemeMap.get(theme.id.toLowerCase()) || localThemeMap.get(theme.name.toLowerCase());
@@ -495,7 +612,8 @@
}
async function handleRefreshOwnedTheme(theme: CommunityTheme) {
if (theme.deletedAt || downloadingId || forkingId || deletingId || moderatingId) return;
if (theme.deletedAt || downloadingId || bulkDownload || forkingId || deletingId || moderatingId)
return;
const installed =
localThemeMap.get(theme.id.toLowerCase()) || localThemeMap.get(theme.name.toLowerCase());
@@ -1486,6 +1604,28 @@
</WorkshopTooltip>
{/if}
{#if selectedView === 'workshop'}
<WorkshopTooltip
content={selectionMode ? 'Exit download selection' : 'Select themes to download'}
>
{#snippet trigger(props)}
<Button
{...props}
variant={selectionMode ? 'secondary' : 'ghost'}
size="icon"
class="size-7.5 text-muted-foreground hover:text-white"
onclick={toggleSelectionMode}
aria-label={selectionMode
? 'Exit download selection'
: 'Select themes to download'}
aria-pressed={selectionMode}
>
<Check class="size-3.5" />
</Button>
{/snippet}
</WorkshopTooltip>
{/if}
{#if $currentUser}
<WorkshopTooltip content={selectedView === 'trash' ? 'Back to workshop' : 'Open trash'}>
{#snippet trigger(props)}
@@ -1523,6 +1663,76 @@
</div>
</div>
{#if selectionMode && selectedView === 'workshop'}
<div
class="mb-2 flex flex-wrap items-center justify-between gap-2 rounded-lg border border-primary/30 bg-primary/10 px-3 py-2"
>
<div class="flex min-w-0 flex-wrap items-center gap-2 text-[11px] text-theme-100">
<span class="font-medium">{selectedThemes.length} selected</span>
{#if selectedThemes.length > downloadableSelectedThemes.length}
<span class="text-muted-foreground">
{selectedThemes.length - downloadableSelectedThemes.length} already installed
</span>
{/if}
<Button
variant="ghost"
size="sm"
class="h-6 px-1.5 text-[10px] text-muted-foreground hover:text-white"
onclick={selectAllDownloadableThemes}
disabled={Boolean(bulkDownload) || themes.length === 0}
>
Select all
</Button>
<Button
variant="ghost"
size="sm"
class="h-6 px-1.5 text-[10px] text-muted-foreground hover:text-white"
onclick={clearThemeSelection}
disabled={Boolean(bulkDownload) || selectedThemes.length === 0}
>
Clear
</Button>
</div>
<div class="flex items-center gap-2">
{#if bulkDownload}
<div class="flex items-center gap-2 text-[10px] text-primary tabular-nums">
<LoaderCircle class="size-3 animate-spin" />
<span class="max-w-48 truncate">
{bulkDownload.current}/{bulkDownload.total} · {bulkDownload.name}
</span>
<div class="h-1.5 w-20 overflow-hidden rounded-full bg-theme-800">
<div
class="h-full bg-primary transition-all duration-200"
style="width: {bulkDownload.progress}%"
></div>
</div>
<span>{bulkDownload.progress}%</span>
</div>
{/if}
<Button
size="sm"
class="h-7 gap-1 text-xs"
onclick={() => void handleBulkDownload()}
disabled={Boolean(bulkDownload) || downloadableSelectedThemes.length === 0}
>
<Download class="size-3" />
Download selected
</Button>
<Button
variant="ghost"
size="icon"
class="size-7 text-muted-foreground hover:text-white"
onclick={toggleSelectionMode}
disabled={Boolean(bulkDownload)}
aria-label="Exit download selection"
>
<X class="size-3.5" />
</Button>
</div>
</div>
{/if}
<!-- Content Grid / Status -->
<div class="min-h-0 flex-1 pt-1">
<ScrollContainer class="pr-2">
@@ -1568,6 +1778,7 @@
localTheme && $active_custom_theme?.folder_name === localTheme.folder_name
)}
{@const isDownloading = downloadingId === theme.id}
{@const isSelected = selectionMode && selectedThemeIds.includes(theme.id)}
{@const isDeleted = Boolean(theme.deletedAt)}
{@const hasUpdate = Boolean(
!isDeleted && localTheme && localTheme.version !== theme.version
@@ -1581,7 +1792,9 @@
{@const showOverflowMenu = !isDeleted && (owned || canModerate || canReport)}
<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 {isSelected
? 'border-primary/70 ring-1 ring-primary/30'
: isActive
? 'border-primary/60 ring-1 ring-primary/20'
: 'border-theme-800/60'} transition-all duration-300 hover:-translate-y-0.5 hover:border-white/20 hover:bg-theme-900/50 hover:shadow-xl hover:shadow-black/30"
>
@@ -1757,11 +1970,21 @@
<div class="flex flex-1 flex-col justify-between gap-2 p-3">
<div class="min-w-0">
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
{#if selectionMode && !isDeleted}
<Checkbox
class="shrink-0"
checked={selectedThemeIds.includes(theme.id)}
onCheckedChange={() => toggleThemeSelection(theme.id)}
aria-label={`Select ${theme.name} for download`}
/>
{/if}
<h3
class="truncate text-[13px] font-bold text-white group-hover:text-primary transition-colors"
>
{theme.name}
</h3>
</div>
<!-- Interactive Like Button -->
{#if isDeleted}