From 6b1ed5aa1a1453e8a37a6c76d887c1197cde99d0 Mon Sep 17 00:00:00 2001 From: HorizonCode Date: Tue, 3 Nov 2026 18:43:53 +0100 Subject: [PATCH] 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. --- .../theme/CommunityWorkshopView.svelte | 245 +++++++++++++++++- 1 file changed, 234 insertions(+), 11 deletions(-) diff --git a/src/lib/components/theme/CommunityWorkshopView.svelte b/src/lib/components/theme/CommunityWorkshopView.svelte index daad70f..ff27266 100644 --- a/src/lib/components/theme/CommunityWorkshopView.svelte +++ b/src/lib/components/theme/CommunityWorkshopView.svelte @@ -119,6 +119,14 @@ let downloadingId = $state(null); let downloadProgress = $state(0); + let selectionMode = $state(false); + let selectedThemeIds = $state([]); + let bulkDownload = $state<{ + current: number; + total: number; + name: string; + progress: number; + } | null>(null); let forkingId = $state(null); let deletingId = $state(null); let moderatingId = $state(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(); @@ -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 @@ {/if} + {#if selectedView === 'workshop'} + + {#snippet trigger(props)} + + {/snippet} + + {/if} + {#if $currentUser} {#snippet trigger(props)} @@ -1523,6 +1663,76 @@ + {#if selectionMode && selectedView === 'workshop'} +
+
+ {selectedThemes.length} selected + {#if selectedThemes.length > downloadableSelectedThemes.length} + + {selectedThemes.length - downloadableSelectedThemes.length} already installed + + {/if} + + +
+ +
+ {#if bulkDownload} +
+ + + {bulkDownload.current}/{bulkDownload.total} ยท {bulkDownload.name} + +
+
+
+ {bulkDownload.progress}% +
+ {/if} + + +
+
+ {/if} +
@@ -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,9 +1792,11 @@ {@const showOverflowMenu = !isDeleted && (owned || canModerate || canReport)}
@@ -1757,11 +1970,21 @@
-

- {theme.name} -

+
+ {#if selectionMode && !isDeleted} + toggleThemeSelection(theme.id)} + aria-label={`Select ${theme.name} for download`} + /> + {/if} +

+ {theme.name} +

+
{#if isDeleted}