diff --git a/src/lib/communityThemes.ts b/src/lib/communityThemes.ts index f15a8b7..66fbb07 100644 --- a/src/lib/communityThemes.ts +++ b/src/lib/communityThemes.ts @@ -44,6 +44,7 @@ export interface CommunityTheme { downloadCount: number; likeCount: number; tags: string[]; + allowForking: boolean; createdAt: string; updatedAt: string; author: CommunityThemeAuthor; @@ -74,6 +75,11 @@ export interface CommunityThemesResponse { canModerate: boolean; } +export interface CommunityTagSummary { + tag: string; + count: number; +} + export type ThemeReportStatus = 'open' | 'resolved' | 'dismissed'; export type ThemeReportCategory = | 'spam' @@ -224,6 +230,29 @@ export async function fetchCommunityThemes(options?: { }; } +export async function fetchCommunityTags(): Promise { + const baseUrl = await getThemesServiceUrl(); + const res = await fetch(`${baseUrl}/v1/themes/tags`); + if (!res.ok) { + const errorJson = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(errorJson.error || `Failed to load tags (HTTP ${res.status})`); + } + + const data = (await res.json()) as { tags?: unknown }; + if (!Array.isArray(data.tags)) return []; + + const tags: CommunityTagSummary[] = []; + for (const item of data.tags) { + if (!item || typeof item !== 'object') continue; + const candidate = item as { tag?: unknown; count?: unknown }; + if (typeof candidate.tag !== 'string' || typeof candidate.count !== 'number') continue; + const tag = candidate.tag.trim().toLowerCase(); + if (!tag) continue; + tags.push({ tag, count: candidate.count }); + } + return tags; +} + export async function fetchCommunityThemeDetails(themeId: string): Promise { const cleanId = themeId.trim().toLowerCase(); const baseUrl = await getThemesServiceUrl(); @@ -656,6 +685,7 @@ export async function publishThemeToWorkshop(options: { version?: string; apiVersion?: string; tags?: string[]; + allowForking?: boolean; previewImageBase64?: string; onProgress?: (status: string, percentage: number) => void; }): Promise<{ success: boolean; themeId: string; theme: unknown }> { @@ -717,6 +747,7 @@ export async function publishThemeToWorkshop(options: { totalChunks, sha256, tags: options.tags || [], + allowForking: options.allowForking, }), }); diff --git a/src/lib/components/theme/CommunityWorkshopView.svelte b/src/lib/components/theme/CommunityWorkshopView.svelte index 7a94087..9313abc 100644 --- a/src/lib/components/theme/CommunityWorkshopView.svelte +++ b/src/lib/components/theme/CommunityWorkshopView.svelte @@ -1,6 +1,7 @@ @@ -1217,17 +1268,110 @@ Your trash {:else} - {#each filterTags as tag (tag)} - + + + - {tag === 'all' ? 'All Tags' : `#${tag}`} - - {/each} + + + {selectedTag === 'all' ? 'Filter by tag' : `#${selectedTag}`} + + {#if tagOptions.length > 0} + + {tagOptions.length} + + {/if} + {#if tagsLoading} + + {:else} + + {/if} + + + +
+
+ + + {#if tagSearch} + + {/if} +
+
+ +
+ + + {#if filteredTagOptions.length === 0} +

+ {tagOptions.length === 0 + ? 'No tags are in use yet.' + : 'No tags match your search.'} +

+ {:else} + {#each filteredTagOptions as option (option.tag)} + + {/each} + {/if} +
+
+
+ {#if canModerate} {#snippet trigger(props)} @@ -1317,7 +1461,10 @@ variant="ghost" size="icon" class="size-7.5 text-muted-foreground hover:text-white" - onclick={() => void loadWorkshopThemes()} + onclick={() => { + void loadWorkshopThemes(); + void loadWorkshopTags(); + }} disabled={loading} > @@ -1371,7 +1518,7 @@ theme.apiVersion === '1.0' || Boolean(theme.tags?.includes('legacy'))} {@const canReport = Boolean($currentUser) && !owned && !theme.isHidden && !theme.isQuarantined} - {@const canFork = canReport && !isLegacy} + {@const canFork = canReport && !isLegacy && theme.allowForking !== false} {@const showOverflowMenu = !isDeleted && (owned || canModerate || canReport)}
{/if} - {#if theme.searchMatch} - {@const searchMatch = theme.searchMatch} - - {#snippet trigger(props)} -
- - - Matched {searchMatchLabel(searchMatch.field).toLowerCase()}: - {searchMatch.value} - -
- {/snippet} -
- {/if} - {#if theme.isHidden || theme.isQuarantined}
{#if theme.isHidden} diff --git a/src/lib/components/theme/ThemeEditorWindow.svelte b/src/lib/components/theme/ThemeEditorWindow.svelte index c08db53..fc3e570 100644 --- a/src/lib/components/theme/ThemeEditorWindow.svelte +++ b/src/lib/components/theme/ThemeEditorWindow.svelte @@ -3422,6 +3422,7 @@ version: definition.version || '1.0.0', apiVersion: definition.apiVersion || '2.0.0', tags: definition.tags ?? [], + allowForking: definition.allowForking !== false, onProgress: (msg, pct) => { publishStatusMessage = msg; publishProgress = pct; @@ -3439,6 +3440,15 @@ } catch (err) { console.warn('[Workshop] Could not store the theme ID in theme.json:', err); } + + // The launcher window owns the installed-theme list. Tell it about the + // new workshop ID so reporting is hidden immediately after publishing. + await emit('theme-published', { + themeId: result.themeId, + name: definition.name, + }).catch((err) => { + console.warn('[Workshop] Could not notify the launcher about the published theme:', err); + }); } sileo.success({ @@ -5073,6 +5083,27 @@ }} />
+
+
+ +

+ Let other creators fork this theme from the workshop. Takes effect when you publish + or update it; existing forks are not affected. +

+
+ { + definition.allowForking = checked === true; + }} + /> +
diff --git a/src/lib/themeV2.ts b/src/lib/themeV2.ts index bba9efa..504c1eb 100644 --- a/src/lib/themeV2.ts +++ b/src/lib/themeV2.ts @@ -653,6 +653,8 @@ export type ThemeV2Definition = { workshopId?: string; /** Marks the theme as adult content in the theme manager. */ nsfw?: boolean; + /** Whether other creators may fork this theme from the workshop. */ + allowForking?: boolean; /** Optional preview image, relative to the theme's assets folder. */ preview?: string; background: ThemeV2Background; @@ -1785,6 +1787,7 @@ export const createDefaultThemeV2 = (): ThemeV2Definition => ({ description: '', tags: [], nsfw: false, + allowForking: true, background: { type: 'gradient', from: '#181825', @@ -1914,6 +1917,7 @@ export const normalizeThemeV2 = (raw: Partial): ThemeV2Defini tags: normalizeThemeTags(raw.tags), workshopId: normalizeWorkshopId(raw.workshopId), nsfw: raw.nsfw === true, + allowForking: raw.allowForking !== false, preview: text(raw.preview), background, overlay: { diff --git a/src/lib/themes.ts b/src/lib/themes.ts index 7e8de14..1aa51af 100644 --- a/src/lib/themes.ts +++ b/src/lib/themes.ts @@ -524,6 +524,7 @@ export const reloadThemes = async () => { apiVersion: theme.apiVersion, description: theme.description, nsfw: theme.nsfw, + workshopId: theme.workshopId, definition: theme.definition, updateAvailable: theme.updateAvailable, availableVersion: theme.availableVersion, diff --git a/src/screens/Launch.svelte b/src/screens/Launch.svelte index 39cd567..82f67fe 100644 --- a/src/screens/Launch.svelte +++ b/src/screens/Launch.svelte @@ -4,6 +4,7 @@ import { ezppfarm } from '$lib/api/ezpp'; import { fetchCommunityThemeDetails, + fetchCommunityThemes, type CommunityTheme, } from '$lib/communityThemes'; import DiagnosticsPanel from '$lib/components/diagnostics/DiagnosticsPanel.svelte'; @@ -292,6 +293,7 @@ let themeSearch = $state(''); let activeThemesTab = $state<'library' | 'workshop'>('library'); let themeSavedUnlisten: (() => void) | undefined; + let themePublishedUnlisten: (() => void) | undefined; let refreshingThemes = $state(false); let reloadingActiveTheme = $state(false); let revealedNsfw = $state([]); @@ -311,6 +313,10 @@ let installedReportOpen = $state(false); let installedReportTarget = $state(null); let installedReportLoading = $state(false); + let ownedWorkshopThemeIds = $state([]); + let ownedWorkshopThemeNames = $state([]); + let ownedWorkshopLoaded = $state(false); + let ownedThemesRequestId = 0; let logViewerOpen = $state(false); let presenceEditorOpen = $state(false); @@ -348,11 +354,90 @@ }); }); - const isOwnInstalledTheme = (theme: Theme) => - Boolean( - $currentUser && - theme.author.trim().toLowerCase() === $currentUser.name.trim().toLowerCase() - ); + const currentUserAliases = $derived.by(() => { + const aliases = new Set(); + const add = (value: string | undefined) => { + const normalized = value?.trim().toLowerCase(); + if (normalized) aliases.add(normalized); + }; + + add($currentUser?.name); + add($currentUserInfo?.info.name); + add($currentUserInfo?.info.safe_name); + for (const name of $currentUserInfo?.info.username_history ?? []) add(name); + return aliases; + }); + + const loadOwnedWorkshopThemes = async (userId: number) => { + const requestId = ++ownedThemesRequestId; + ownedWorkshopLoaded = false; + const ids = new Set(); + const names = new Set(); + + try { + let page = 1; + let totalPages = 1; + do { + const response = await fetchCommunityThemes({ authorId: userId, page, limit: 100 }); + if (requestId !== ownedThemesRequestId) return; + + for (const item of response.items) { + ids.add(item.id.trim().toLowerCase()); + names.add(item.name.trim().toLowerCase()); + } + totalPages = response.totalPages; + page += 1; + } while (page <= totalPages && page <= 20); + } catch (err) { + console.warn('[Themes] Could not load owned workshop themes:', err); + } finally { + if (requestId === ownedThemesRequestId) { + // Merge instead of replacing so a theme published while this request + // was in flight is not lost from the ownership cache. + ownedWorkshopThemeIds = [...new Set([...ownedWorkshopThemeIds, ...ids])]; + ownedWorkshopThemeNames = [...new Set([...ownedWorkshopThemeNames, ...names])]; + ownedWorkshopLoaded = true; + } + } + }; + + const isOwnInstalledTheme = (theme: Theme) => { + if (!$currentUser) return false; + + const folderName = theme.folder_name.trim().toLowerCase(); + const workshopId = theme.workshopId?.trim().toLowerCase(); + if ( + ownedWorkshopThemeIds.includes(folderName) || + (workshopId !== undefined && ownedWorkshopThemeIds.includes(workshopId)) + ) { + return true; + } + + if (currentUserAliases.has(theme.author.trim().toLowerCase())) return true; + + // Older installs do not have a workshopId, so fall back to the name of a + // theme already published by this account. + return ownedWorkshopThemeNames.includes(theme.name.trim().toLowerCase()); + }; + + onMount(() => { + let loadedUserId: number | undefined; + return currentUser.subscribe((user) => { + if (!user) { + loadedUserId = undefined; + ownedThemesRequestId += 1; + ownedWorkshopThemeIds = []; + ownedWorkshopThemeNames = []; + ownedWorkshopLoaded = false; + return; + } + if (loadedUserId === user.id) return; + loadedUserId = user.id; + ownedWorkshopThemeIds = []; + ownedWorkshopThemeNames = []; + void loadOwnedWorkshopThemes(user.id); + }); + }); const themeButtonLabel = (theme: Theme, activeFolder: string | undefined) => { if (activeFolder === theme.folder_name) return 'Theme in use'; @@ -459,7 +544,7 @@ installedReportLoading = true; try { - const target = await fetchCommunityThemeDetails(theme.folder_name); + const target = await fetchCommunityThemeDetails(theme.workshopId ?? theme.folder_name); if (target.isOwner) { sileo.error({ title: 'You cannot report your own theme', @@ -1582,9 +1667,30 @@ } }).then((unlisten) => (themeSavedUnlisten = unlisten)); + listen<{ themeId?: string; name?: string }>('theme-published', async (event) => { + if (!$currentUser) return; + + const themeId = event.payload.themeId?.trim().toLowerCase(); + const themeName = event.payload.name?.trim().toLowerCase(); + if (themeId) { + ownedWorkshopThemeIds = [...new Set([...ownedWorkshopThemeIds, themeId])]; + } + if (themeName) { + ownedWorkshopThemeNames = [...new Set([...ownedWorkshopThemeNames, themeName])]; + } + if (themeId || themeName) ownedWorkshopLoaded = true; + + try { + await reloadThemes(); + } catch (error) { + console.error(error); + } + }).then((unlisten) => (themePublishedUnlisten = unlisten)); + return () => { window.clearInterval(animateInterval); themeSavedUnlisten?.(); + themePublishedUnlisten?.(); stopThemeScript(); }; }); @@ -4042,7 +4148,7 @@ {/if} {/if} - {#if $currentUser && !isOwnInstalledTheme(theme) && theme.status === 'installed' && theme.name !== 'Default' && theme.folder_name !== 'default'} + {#if $currentUser && ownedWorkshopLoaded && !isOwnInstalledTheme(theme) && theme.status === 'installed' && theme.name !== 'Default' && theme.folder_name !== 'default'}