From 1f3fa026ce370bdf79b60883339d80397b669547 Mon Sep 17 00:00:00 2001 From: HorizonCode Date: Tue, 3 Nov 2026 13:15:37 +0100 Subject: [PATCH] feat(workshop): add role badges and streamline theme cards Use the service owner, moderator, or supporter role for author badges, expose downloads for owned legacy themes, and move secondary card actions into a menu. Include theme tags in publish metadata and keep API 2.0 exports limited to theme.json and assets. --- src-tauri/src/commands.rs | 34 +- src/lib/communityThemes.ts | 1 + .../theme/CommunityWorkshopView.svelte | 440 ++++++++---------- .../components/theme/ThemeEditorWindow.svelte | 199 +++++--- src/lib/themeV2.ts | 20 + 5 files changed, 383 insertions(+), 311 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a253608..b15fb3f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3088,7 +3088,18 @@ fn add_theme_folder_to_zip( } let entry_path = entry.path(); + let relative = entry_path + .strip_prefix(root) + .map_err(|e| format!("Failed to build archive path: {e}"))? + .to_string_lossy() + .replace('\\', "/"); + if file_type.is_dir() { + // API 2.0 packages are limited to theme.json and assets/. Do not + // copy README files, editor artifacts, or any other root folder. + if asset_filter.is_some() && directory == root && relative != "assets" { + continue; + } add_theme_folder_to_zip(zip, root, &entry_path, asset_filter, options)?; continue; } @@ -3096,19 +3107,18 @@ fn add_theme_folder_to_zip( continue; } - if let Some(filter) = asset_filter - && let Ok(asset_path) = entry_path.strip_prefix(&assets_root) { - let key = asset_path.to_string_lossy().replace('\\', "/"); - if !filter.contains(&key) { - continue; - } - } + if asset_filter.is_some() && directory == root && relative != "theme.json" { + continue; + } - let relative = entry_path - .strip_prefix(root) - .map_err(|e| format!("Failed to build archive path: {e}"))? - .to_string_lossy() - .replace('\\', "/"); + if let Some(filter) = asset_filter + && let Ok(asset_path) = entry_path.strip_prefix(&assets_root) + { + let key = asset_path.to_string_lossy().replace('\\', "/"); + if !filter.contains(&key) { + continue; + } + } zip.start_file(relative, options) .map_err(|e| format!("Failed to add a file to the theme archive: {e}"))?; diff --git a/src/lib/communityThemes.ts b/src/lib/communityThemes.ts index 1fb98c5..90178c0 100644 --- a/src/lib/communityThemes.ts +++ b/src/lib/communityThemes.ts @@ -9,6 +9,7 @@ export interface CommunityThemeAuthor { id: number; name: string; donor: boolean; + role?: 'owner' | 'moderator' | 'supporter' | null; avatarUrl: string; } diff --git a/src/lib/components/theme/CommunityWorkshopView.svelte b/src/lib/components/theme/CommunityWorkshopView.svelte index c2a592a..f50a667 100644 --- a/src/lib/components/theme/CommunityWorkshopView.svelte +++ b/src/lib/components/theme/CommunityWorkshopView.svelte @@ -32,6 +32,7 @@ import { isThemeTrusted } from '$lib/themeTrust'; import type { Theme } from '$lib/themes'; import { Button } from '$lib/components/ui/button'; + import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import { Input } from '$lib/components/ui/input'; import Badge from '$lib/components/ui/badge/badge.svelte'; import ScrollContainer from '$lib/components/ui/scroll-container/ScrollContainer.svelte'; @@ -46,7 +47,9 @@ ChevronDown, Code2, CopyPlus, + Crown, Download, + Ellipsis, ExternalLink, Eye, EyeOff, @@ -1255,6 +1258,13 @@ {@const isDeleted = Boolean(theme.deletedAt)} {@const hasUpdate = Boolean(!isDeleted && localTheme && localTheme.version !== theme.version)} {@const owned = isOwnedByCurrentUser(theme)} + {@const isLegacy = + theme.apiVersion === '1.0' || Boolean(theme.tags?.includes('legacy'))} + {@const showOverflowMenu = + !isDeleted && + (owned || + canModerate || + Boolean($currentUser && !isLegacy && !theme.isHidden && !theme.isQuarantined))}
- {#snippet trigger(props)} - - - Declarative - - {/snippet} - - {:else} + {:else if theme.hasExecutableCode} {#snippet trigger(props)} {theme.author.name} - {#if theme.author.donor} + {#if theme.author.role === 'owner'} + + {#snippet trigger(props)} + + + Owner + + {/snippet} + + {:else if theme.author.role === 'moderator'} + + {#snippet trigger(props)} + + + Moderator + + {/snippet} + + {:else if theme.author.role === 'supporter' || (!theme.author.role && theme.author.donor)} {#snippet trigger(props)} Supporter @@ -1458,28 +1481,6 @@
{/if} - {#if owned && !isDeleted} - - {#snippet trigger(props)} - - {/snippet} - - {/if} - {#if theme.searchMatch} {@const searchMatch = theme.searchMatch} {/if} - - {#if canModerate} - - {#snippet trigger(props)} - - {/snippet} - - {/if} -
-
- - {theme.downloadCount} +
+
+ + + {theme.downloadCount} + + {#if isInstalled && !isActive} + + Installed + + {/if}
-
+
{#if isDownloading}
- {downloadProgress}% + {downloadProgress}%
Updating - {:else if owned} - {#if isDeleted} + {:else if isDeleted} + {#if owned} - {/snippet} - - {/if} - {#if canModerate} - - {#snippet trigger(props)} - - {/snippet} - - {/if} - {#if isInstalled && hasUpdate} - - {#snippet trigger(props)} - - {/snippet} - - {/if} + + Deleted + + {/if} + {:else} + {#if owned && !isLegacy && isInstalled} - - {#snippet trigger(props)} - - {/snippet} - - {/if} - {:else} - {#if isDeleted} - - Deleted + {:else if isActive} + + In use + {:else if isInstalled && hasUpdate} + + {:else if isInstalled} + {:else} - {#if canModerate && theme.apiVersion !== '1.0' && !theme.tags?.includes('legacy')} - - {#snippet trigger(props)} - - {/snippet} - - {/if} - {#if canModerate} - - {#snippet trigger(props)} - - {/snippet} - - {/if} - {#if $currentUser && !theme.isHidden && !theme.isQuarantined && theme.apiVersion !== '1.0' && !theme.tags?.includes('legacy')} - - {#snippet trigger(props)} - - {/snippet} - - {/if} - {#if theme.apiVersion !== '1.0' && !theme.tags?.includes('legacy')} - - {#snippet trigger(props)} + + {/if} + + {#if showOverflowMenu} + + + {#snippet child({ props })} + {/snippet} + + + {#if owned} + void handleEditTags(theme)} + disabled={tagEditingId === theme.id} + > + {#if tagEditingId === theme.id} + + Saving tags... + {:else} + + Edit tags + {/if} + + {#if !isLegacy && isInstalled && hasUpdate} + handleRefreshOwnedTheme(theme)} + > + + Refresh installed copy + + {/if} + {:else if $currentUser && !isLegacy && !theme.isHidden && !theme.isQuarantined} + openReportDialog(theme)} + > + + Report theme + + handleForkTheme(theme)} > - - {/snippet} - - {/if} - {#if isActive} - - In use - - {:else if isInstalled && hasUpdate} - - {:else if isInstalled} - - {:else} - - {/if} + Fork theme + + {/if} + + {#if canModerate} + {#if owned || ($currentUser && !isLegacy && !theme.isHidden && !theme.isQuarantined)} + + {/if} + {#if !isLegacy} + handleToggleVisibility(theme)} + > + {#if theme.isHidden} + + Restore visibility + {:else} + + Hide from workshop + {/if} + + {/if} + handleToggleQuarantine(theme)} + disabled={quarantineId === theme.id} + > + {#if quarantineId === theme.id} + + Updating... + {:else} + + {theme.isQuarantined ? 'Lift takedown' : 'Take down theme'} + {/if} + + void openThemeHistory(theme.id, theme.name)} + > + + Moderation history + + {/if} + + {#if owned} + + handleDeleteTheme(theme)} + > + + Move to trash + + {/if} + + {/if} {/if}
diff --git a/src/lib/components/theme/ThemeEditorWindow.svelte b/src/lib/components/theme/ThemeEditorWindow.svelte index 33a671c..36d7fc5 100644 --- a/src/lib/components/theme/ThemeEditorWindow.svelte +++ b/src/lib/components/theme/ThemeEditorWindow.svelte @@ -72,6 +72,7 @@ createThemeV2Behavior, createThemeV2Element, normalizeChrome, + normalizeThemeTags, normalizeThemeV2, themeAssetName, themeHasLaunchButton, @@ -3333,11 +3334,8 @@ let publishModalOpen = $state(false); let publishThemeId = $state(''); - let publishName = $state(''); - let publishDescription = $state(''); - let publishVersion = $state('1.0.0'); - let publishTags = $state('anime, video'); let publishing = $state(false); + let tagInput = $state(''); let publishStatusMessage = $state(''); let publishProgress = $state(0); @@ -3367,15 +3365,37 @@ } publishThemeId = savedFolder || toSafeName(definition.name); - publishName = definition.name || 'My Theme'; - publishDescription = definition.description || ''; - publishVersion = definition.version || '1.0.0'; - publishTags = 'workshop, v2'; publishProgress = 0; publishStatusMessage = ''; publishModalOpen = true; } + function addThemeTag() { + const value = tagInput.trim().toLowerCase(); + if (!value) return; + + const current = definition.tags ?? []; + if (current.length >= 10) { + notifyError('A theme may have at most 10 tags.'); + return; + } + + const tags = normalizeThemeTags([...current, value]); + if (!tags.includes(value)) { + notifyError( + 'Tags may contain lowercase letters, numbers, dashes and underscores (max 24 characters).' + ); + return; + } + + definition.tags = tags; + tagInput = ''; + } + + function removeThemeTag(tag: string) { + definition.tags = (definition.tags ?? []).filter((candidate) => candidate !== tag); + } + async function handlePublishToWorkshop() { if (publishing) return; if (!savedFolder) { @@ -3390,18 +3410,16 @@ try { const baseThemesDir = await themesDirectory(); const themeFolder = await path.join(baseThemesDir, savedFolder); + const themeId = savedFolder || toSafeName(definition.name); await publishThemeToWorkshop({ themeFolder, - themeId: publishThemeId, - name: publishName, - description: publishDescription, - version: publishVersion, + themeId, + name: definition.name, + description: definition.description || '', + version: definition.version || '1.0.0', apiVersion: definition.apiVersion || '2.0.0', - tags: publishTags - .split(',') - .map((t) => t.trim()) - .filter(Boolean), + tags: definition.tags ?? [], onProgress: (msg, pct) => { publishStatusMessage = msg; publishProgress = pct; @@ -3410,7 +3428,7 @@ sileo.success({ title: 'Published to Workshop!', - description: `"${publishName}" is now live in the Community Themes Workshop.`, + description: `"${definition.name}" is now live in the Community Themes Workshop.`, }); publishModalOpen = false; @@ -4963,6 +4981,63 @@ class="min-h-16 resize-y border-theme-800 bg-theme-950 text-xs text-theme-50" />
+
+
+ + + {(definition.tags ?? []).length}/10 + +
+
+ { + if (event.key === 'Enter' || event.key === ',') { + event.preventDefault(); + addThemeTag(); + } + }} + onblur={addThemeTag} + /> + +
+ {#if (definition.tags ?? []).length > 0} +
+ {#each definition.tags ?? [] as tag (tag)} + + {tag} + + + {/each} +
+ {:else} +

+ Add tags to help people discover this theme. +

+ {/if} +
@@ -6805,58 +6880,52 @@
{/if} -
-
- - +
+
+
+

+ {definition.name || 'Untitled theme'} +

+

+ by {definition.author || 'Unknown'} ยท v{definition.version || '1.0.0'} +

+
+ + API {definition.apiVersion || '2.0.0'} +
- -
-
- - +

+ {definition.description?.trim() || 'No description yet.'} +

+
+
+ + Workshop ID + +

{publishThemeId}

- - + + Tags + + {#if (definition.tags ?? []).length > 0} +
+ {#each definition.tags ?? [] as tag} + + {tag} + + {/each} +
+ {:else} +

No tags

+ {/if}
- -
- -