feat(themes): improve workshop filtering and fork controls
Add a compact used-tag filter, preserve workshop ownership across theme reloads, and let authors disable forking for their themes.
This commit is contained in:
1 parent
ebc5fbf9c4
commit
3ef59f94ee
6 files changed
+343
-44
No files matched your search
@@ -44,6 +44,7 @@ export interface CommunityTheme {
|
|||||||
downloadCount: number;
|
downloadCount: number;
|
||||||
likeCount: number;
|
likeCount: number;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
allowForking: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
author: CommunityThemeAuthor;
|
author: CommunityThemeAuthor;
|
||||||
@@ -74,6 +75,11 @@ export interface CommunityThemesResponse {
|
|||||||
canModerate: boolean;
|
canModerate: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CommunityTagSummary {
|
||||||
|
tag: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type ThemeReportStatus = 'open' | 'resolved' | 'dismissed';
|
export type ThemeReportStatus = 'open' | 'resolved' | 'dismissed';
|
||||||
export type ThemeReportCategory =
|
export type ThemeReportCategory =
|
||||||
| 'spam'
|
| 'spam'
|
||||||
@@ -224,6 +230,29 @@ export async function fetchCommunityThemes(options?: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchCommunityTags(): Promise<CommunityTagSummary[]> {
|
||||||
|
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<CommunityTheme> {
|
export async function fetchCommunityThemeDetails(themeId: string): Promise<CommunityTheme> {
|
||||||
const cleanId = themeId.trim().toLowerCase();
|
const cleanId = themeId.trim().toLowerCase();
|
||||||
const baseUrl = await getThemesServiceUrl();
|
const baseUrl = await getThemesServiceUrl();
|
||||||
@@ -656,6 +685,7 @@ export async function publishThemeToWorkshop(options: {
|
|||||||
version?: string;
|
version?: string;
|
||||||
apiVersion?: string;
|
apiVersion?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
allowForking?: boolean;
|
||||||
previewImageBase64?: string;
|
previewImageBase64?: string;
|
||||||
onProgress?: (status: string, percentage: number) => void;
|
onProgress?: (status: string, percentage: number) => void;
|
||||||
}): Promise<{ success: boolean; themeId: string; theme: unknown }> {
|
}): Promise<{ success: boolean; themeId: string; theme: unknown }> {
|
||||||
@@ -717,6 +747,7 @@ export async function publishThemeToWorkshop(options: {
|
|||||||
totalChunks,
|
totalChunks,
|
||||||
sha256,
|
sha256,
|
||||||
tags: options.tags || [],
|
tags: options.tags || [],
|
||||||
|
allowForking: options.allowForking,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import {
|
import {
|
||||||
|
fetchCommunityTags,
|
||||||
fetchCommunityThemes,
|
fetchCommunityThemes,
|
||||||
fetchCommunityThemeDetails,
|
fetchCommunityThemeDetails,
|
||||||
toggleCommunityThemeLike,
|
toggleCommunityThemeLike,
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
downloadThemeReportsCsv,
|
downloadThemeReportsCsv,
|
||||||
downloadModerationEventsCsv,
|
downloadModerationEventsCsv,
|
||||||
fetchModerationEvents,
|
fetchModerationEvents,
|
||||||
|
type CommunityTagSummary,
|
||||||
type CommunityTheme,
|
type CommunityTheme,
|
||||||
type ModerationEvent,
|
type ModerationEvent,
|
||||||
type ModerationEventQuery,
|
type ModerationEventQuery,
|
||||||
@@ -39,6 +41,7 @@
|
|||||||
import * as Dialog from '$lib/components/ui/dialog';
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import ScrollContainer from '$lib/components/ui/scroll-container/ScrollContainer.svelte';
|
import ScrollContainer from '$lib/components/ui/scroll-container/ScrollContainer.svelte';
|
||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
@@ -94,6 +97,11 @@
|
|||||||
|
|
||||||
let searchQuery = $state('');
|
let searchQuery = $state('');
|
||||||
let selectedTag = $state('all');
|
let selectedTag = $state('all');
|
||||||
|
let tagFilterOpen = $state(false);
|
||||||
|
let tagSearch = $state('');
|
||||||
|
let communityTags = $state<CommunityTagSummary[]>([]);
|
||||||
|
let tagsLoading = $state(false);
|
||||||
|
let tagsRequestId = 0;
|
||||||
let selectedSort = $state<WorkshopSort>('relevance');
|
let selectedSort = $state<WorkshopSort>('relevance');
|
||||||
let selectedView = $state<WorkshopView>('workshop');
|
let selectedView = $state<WorkshopView>('workshop');
|
||||||
let showHiddenOnly = $state(false);
|
let showHiddenOnly = $state(false);
|
||||||
@@ -173,10 +181,6 @@
|
|||||||
let sourceLoading = $state(false);
|
let sourceLoading = $state(false);
|
||||||
let sourceError = $state<string | null>(null);
|
let sourceError = $state<string | null>(null);
|
||||||
|
|
||||||
const TAGS = ['all', 'legacy', 'anime', 'minimal', 'video', 'audio', 'game', 'dark', 'clean'];
|
|
||||||
const filterTags = $derived(
|
|
||||||
selectedTag !== 'all' && !TAGS.includes(selectedTag) ? [...TAGS, selectedTag] : TAGS
|
|
||||||
);
|
|
||||||
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' },
|
||||||
{ id: 'harassment', label: 'Harassment / hate' },
|
{ id: 'harassment', label: 'Harassment / hate' },
|
||||||
@@ -224,6 +228,31 @@
|
|||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tagOptions = $derived.by(() => {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
|
||||||
|
// The service returns every used tag. While it is unavailable or loading,
|
||||||
|
// fall back to tags visible on the current page so filtering still works.
|
||||||
|
if (communityTags.length > 0) {
|
||||||
|
for (const item of communityTags) counts.set(item.tag, item.count);
|
||||||
|
} else {
|
||||||
|
for (const theme of themes) {
|
||||||
|
for (const tag of theme.tags) counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTag !== 'all' && !counts.has(selectedTag)) counts.set(selectedTag, 0);
|
||||||
|
|
||||||
|
return [...counts.entries()]
|
||||||
|
.map(([tag, count]) => ({ tag, count }))
|
||||||
|
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredTagOptions = $derived.by(() => {
|
||||||
|
const query = tagSearch.trim().toLowerCase();
|
||||||
|
return query ? tagOptions.filter((option) => option.tag.includes(query)) : tagOptions;
|
||||||
|
});
|
||||||
|
|
||||||
const isOwnedByCurrentUser = (theme: CommunityTheme) =>
|
const isOwnedByCurrentUser = (theme: CommunityTheme) =>
|
||||||
theme.isOwner || ($currentUser !== undefined && theme.author.id === $currentUser.id);
|
theme.isOwner || ($currentUser !== undefined && theme.author.id === $currentUser.id);
|
||||||
|
|
||||||
@@ -235,6 +264,19 @@
|
|||||||
selectableReports.every((report) => selectedReportIds.includes(report.id))
|
selectableReports.every((report) => selectedReportIds.includes(report.id))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
async function loadWorkshopTags() {
|
||||||
|
const requestId = ++tagsRequestId;
|
||||||
|
tagsLoading = true;
|
||||||
|
try {
|
||||||
|
const tags = await fetchCommunityTags();
|
||||||
|
if (requestId === tagsRequestId) communityTags = tags;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Workshop] Failed to load tag list:', err);
|
||||||
|
} finally {
|
||||||
|
if (requestId === tagsRequestId) tagsLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadWorkshopThemes(options: { append?: boolean } = {}) {
|
async function loadWorkshopThemes(options: { append?: boolean } = {}) {
|
||||||
const append = options.append === true;
|
const append = options.append === true;
|
||||||
const targetPage = append ? page + 1 : 1;
|
const targetPage = append ? page + 1 : 1;
|
||||||
@@ -292,6 +334,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleTagClick(tag: string) {
|
function handleTagClick(tag: string) {
|
||||||
|
tagFilterOpen = false;
|
||||||
|
tagSearch = '';
|
||||||
if (selectedTag === tag && selectedView === 'workshop') return;
|
if (selectedTag === tag && selectedView === 'workshop') return;
|
||||||
selectedView = 'workshop';
|
selectedView = 'workshop';
|
||||||
selectedTag = tag;
|
selectedTag = tag;
|
||||||
@@ -656,6 +700,7 @@
|
|||||||
tagEditorTags = [];
|
tagEditorTags = [];
|
||||||
tagEditorInput = '';
|
tagEditorInput = '';
|
||||||
await loadWorkshopThemes();
|
await loadWorkshopThemes();
|
||||||
|
void loadWorkshopTags();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
tagEditorError = err instanceof Error ? err.message : String(err);
|
tagEditorError = err instanceof Error ? err.message : String(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -664,7 +709,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleForkTheme(theme: CommunityTheme) {
|
async function handleForkTheme(theme: CommunityTheme) {
|
||||||
if (theme.deletedAt || theme.isHidden || downloadingId || forkingId || deletingId || moderatingId) return;
|
if (
|
||||||
|
theme.deletedAt ||
|
||||||
|
theme.isHidden ||
|
||||||
|
theme.allowForking === false ||
|
||||||
|
downloadingId ||
|
||||||
|
forkingId ||
|
||||||
|
deletingId ||
|
||||||
|
moderatingId
|
||||||
|
)
|
||||||
|
return;
|
||||||
if (!$currentUser) {
|
if (!$currentUser) {
|
||||||
sileo.error({
|
sileo.error({
|
||||||
title: 'Login required',
|
title: 'Login required',
|
||||||
@@ -1182,10 +1236,6 @@
|
|||||||
return REPORT_CATEGORIES.find((item) => item.id === category)?.label ?? category;
|
return REPORT_CATEGORIES.find((item) => item.id === category)?.label ?? category;
|
||||||
}
|
}
|
||||||
|
|
||||||
function searchMatchLabel(field: 'id' | 'name' | 'tag' | 'author' | 'description'): string {
|
|
||||||
return field === 'id' ? 'ID' : field === 'name' ? 'Name' : field === 'tag' ? 'Tag' : field === 'author' ? 'Author' : 'Description';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatModerationDate(value: string): string {
|
function formatModerationDate(value: string): string {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||||
@@ -1200,7 +1250,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
loadWorkshopThemes();
|
void loadWorkshopThemes();
|
||||||
|
void loadWorkshopTags();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -1217,17 +1268,110 @@
|
|||||||
Your trash
|
Your trash
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
{#each filterTags as tag (tag)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors {selectedTag === tag
|
class="rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors {selectedTag === 'all'
|
||||||
? 'bg-primary text-primary-foreground shadow-sm'
|
? 'bg-primary text-primary-foreground shadow-sm'
|
||||||
: 'bg-theme-900/60 text-muted-foreground hover:bg-theme-800/60 hover:text-white'}"
|
: 'bg-theme-900/60 text-muted-foreground hover:bg-theme-800/60 hover:text-white'}"
|
||||||
onclick={() => handleTagClick(tag)}
|
onclick={() => handleTagClick('all')}
|
||||||
>
|
>
|
||||||
{tag === 'all' ? 'All Tags' : `#${tag}`}
|
All Tags
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Popover.Root bind:open={tagFilterOpen}>
|
||||||
|
<Popover.Trigger
|
||||||
|
class="flex h-7 max-w-44 items-center gap-1.5 rounded-full border px-2.5 text-[11px] font-medium transition-colors {selectedTag ===
|
||||||
|
'all'
|
||||||
|
? 'border-theme-800 bg-theme-900/60 text-muted-foreground hover:bg-theme-800/60 hover:text-white'
|
||||||
|
: 'border-primary/40 bg-primary/15 text-primary'}"
|
||||||
|
aria-label="Filter workshop by tag"
|
||||||
|
>
|
||||||
|
<Tag class="size-3 shrink-0" />
|
||||||
|
<span class="truncate">
|
||||||
|
{selectedTag === 'all' ? 'Filter by tag' : `#${selectedTag}`}
|
||||||
|
</span>
|
||||||
|
{#if tagOptions.length > 0}
|
||||||
|
<span
|
||||||
|
class="shrink-0 rounded bg-theme-800/80 px-1 text-[9px] tabular-nums text-muted-foreground"
|
||||||
|
>
|
||||||
|
{tagOptions.length}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if tagsLoading}
|
||||||
|
<LoaderCircle class="size-3 shrink-0 animate-spin" />
|
||||||
|
{:else}
|
||||||
|
<ChevronDown class="size-3 shrink-0 opacity-60" />
|
||||||
|
{/if}
|
||||||
|
</Popover.Trigger>
|
||||||
|
|
||||||
|
<Popover.Content
|
||||||
|
align="start"
|
||||||
|
class="w-72 border-theme-800 bg-theme-950 p-0 text-theme-50 shadow-2xl"
|
||||||
|
>
|
||||||
|
<div class="border-b border-theme-800/70 p-2">
|
||||||
|
<div class="relative">
|
||||||
|
<Search
|
||||||
|
class="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
bind:value={tagSearch}
|
||||||
|
class="h-8 border-theme-800 bg-theme-900/60 pr-8 pl-8 text-[11px]"
|
||||||
|
placeholder="Search used tags..."
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
{#if tagSearch}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground transition-colors hover:text-white"
|
||||||
|
aria-label="Clear tag search"
|
||||||
|
onclick={() => (tagSearch = '')}
|
||||||
|
>
|
||||||
|
<X class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-64 overflow-y-auto p-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center justify-between rounded px-2 py-1.5 text-[11px] transition-colors {selectedTag ===
|
||||||
|
'all'
|
||||||
|
? 'bg-primary/15 text-primary'
|
||||||
|
: 'text-muted-foreground hover:bg-theme-800/60 hover:text-white'}"
|
||||||
|
onclick={() => handleTagClick('all')}
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-2"><Tag class="size-3" /> All tags</span>
|
||||||
|
{#if selectedTag === 'all'}<Check class="size-3" />{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if filteredTagOptions.length === 0}
|
||||||
|
<p class="px-2 py-4 text-center text-[10px] text-muted-foreground">
|
||||||
|
{tagOptions.length === 0
|
||||||
|
? 'No tags are in use yet.'
|
||||||
|
: 'No tags match your search.'}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
{#each filteredTagOptions as option (option.tag)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-[11px] transition-colors {selectedTag ===
|
||||||
|
option.tag
|
||||||
|
? 'bg-primary/15 text-primary'
|
||||||
|
: 'text-muted-foreground hover:bg-theme-800/60 hover:text-white'}"
|
||||||
|
onclick={() => handleTagClick(option.tag)}
|
||||||
|
>
|
||||||
|
<span class="truncate">#{option.tag}</span>
|
||||||
|
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground">
|
||||||
|
{option.count}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
|
|
||||||
{#if canModerate}
|
{#if canModerate}
|
||||||
<WorkshopTooltip content="Show only hidden themes">
|
<WorkshopTooltip content="Show only hidden themes">
|
||||||
{#snippet trigger(props)}
|
{#snippet trigger(props)}
|
||||||
@@ -1317,7 +1461,10 @@
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="size-7.5 text-muted-foreground hover:text-white"
|
class="size-7.5 text-muted-foreground hover:text-white"
|
||||||
onclick={() => void loadWorkshopThemes()}
|
onclick={() => {
|
||||||
|
void loadWorkshopThemes();
|
||||||
|
void loadWorkshopTags();
|
||||||
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<RefreshCw class="size-3.5 {loading ? 'animate-spin' : ''}" />
|
<RefreshCw class="size-3.5 {loading ? 'animate-spin' : ''}" />
|
||||||
@@ -1371,7 +1518,7 @@
|
|||||||
theme.apiVersion === '1.0' || Boolean(theme.tags?.includes('legacy'))}
|
theme.apiVersion === '1.0' || Boolean(theme.tags?.includes('legacy'))}
|
||||||
{@const canReport =
|
{@const canReport =
|
||||||
Boolean($currentUser) && !owned && !theme.isHidden && !theme.isQuarantined}
|
Boolean($currentUser) && !owned && !theme.isHidden && !theme.isQuarantined}
|
||||||
{@const canFork = canReport && !isLegacy}
|
{@const canFork = canReport && !isLegacy && theme.allowForking !== false}
|
||||||
{@const showOverflowMenu = !isDeleted && (owned || canModerate || canReport)}
|
{@const showOverflowMenu = !isDeleted && (owned || canModerate || canReport)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -1602,27 +1749,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if theme.searchMatch}
|
|
||||||
{@const searchMatch = theme.searchMatch}
|
|
||||||
<WorkshopTooltip
|
|
||||||
content={`Matched ${searchMatchLabel(searchMatch.field)}: ${searchMatch.value}`}
|
|
||||||
>
|
|
||||||
{#snippet trigger(props)}
|
|
||||||
<div
|
|
||||||
{...props}
|
|
||||||
tabindex="-1"
|
|
||||||
class="mt-2 flex max-w-full items-center gap-1 rounded border border-cyan-900/40 bg-cyan-950/20 px-1.5 py-1 text-[9px] text-cyan-200/80"
|
|
||||||
>
|
|
||||||
<Search class="size-3 shrink-0" />
|
|
||||||
<span class="truncate">
|
|
||||||
Matched {searchMatchLabel(searchMatch.field).toLowerCase()}:
|
|
||||||
{searchMatch.value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</WorkshopTooltip>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if theme.isHidden || theme.isQuarantined}
|
{#if theme.isHidden || theme.isQuarantined}
|
||||||
<div class="mt-2 space-y-1 rounded-md border border-red-900/40 bg-red-950/15 px-2 py-1.5 text-[9px] leading-relaxed">
|
<div class="mt-2 space-y-1 rounded-md border border-red-900/40 bg-red-950/15 px-2 py-1.5 text-[9px] leading-relaxed">
|
||||||
{#if theme.isHidden}
|
{#if theme.isHidden}
|
||||||
|
|||||||
@@ -3422,6 +3422,7 @@
|
|||||||
version: definition.version || '1.0.0',
|
version: definition.version || '1.0.0',
|
||||||
apiVersion: definition.apiVersion || '2.0.0',
|
apiVersion: definition.apiVersion || '2.0.0',
|
||||||
tags: definition.tags ?? [],
|
tags: definition.tags ?? [],
|
||||||
|
allowForking: definition.allowForking !== false,
|
||||||
onProgress: (msg, pct) => {
|
onProgress: (msg, pct) => {
|
||||||
publishStatusMessage = msg;
|
publishStatusMessage = msg;
|
||||||
publishProgress = pct;
|
publishProgress = pct;
|
||||||
@@ -3439,6 +3440,15 @@
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[Workshop] Could not store the theme ID in theme.json:', 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({
|
sileo.success({
|
||||||
@@ -5073,6 +5083,27 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-start justify-between gap-3 rounded-md border border-theme-800 bg-theme-950 px-3 py-2.5"
|
||||||
|
>
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<Label for="theme-allow-forking" class="text-xs font-medium text-theme-100">
|
||||||
|
Allow forking
|
||||||
|
</Label>
|
||||||
|
<p class="text-[10px] leading-relaxed text-muted-foreground">
|
||||||
|
Let other creators fork this theme from the workshop. Takes effect when you publish
|
||||||
|
or update it; existing forks are not affected.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Checkbox
|
||||||
|
id="theme-allow-forking"
|
||||||
|
class="mt-0.5"
|
||||||
|
checked={definition.allowForking !== false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
definition.allowForking = checked === true;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="theme-accent" class={labelClass}>Accent</Label>
|
<Label for="theme-accent" class={labelClass}>Accent</Label>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|||||||
@@ -653,6 +653,8 @@ export type ThemeV2Definition = {
|
|||||||
workshopId?: string;
|
workshopId?: string;
|
||||||
/** Marks the theme as adult content in the theme manager. */
|
/** Marks the theme as adult content in the theme manager. */
|
||||||
nsfw?: boolean;
|
nsfw?: boolean;
|
||||||
|
/** Whether other creators may fork this theme from the workshop. */
|
||||||
|
allowForking?: boolean;
|
||||||
/** Optional preview image, relative to the theme's assets folder. */
|
/** Optional preview image, relative to the theme's assets folder. */
|
||||||
preview?: string;
|
preview?: string;
|
||||||
background: ThemeV2Background;
|
background: ThemeV2Background;
|
||||||
@@ -1785,6 +1787,7 @@ export const createDefaultThemeV2 = (): ThemeV2Definition => ({
|
|||||||
description: '',
|
description: '',
|
||||||
tags: [],
|
tags: [],
|
||||||
nsfw: false,
|
nsfw: false,
|
||||||
|
allowForking: true,
|
||||||
background: {
|
background: {
|
||||||
type: 'gradient',
|
type: 'gradient',
|
||||||
from: '#181825',
|
from: '#181825',
|
||||||
@@ -1914,6 +1917,7 @@ export const normalizeThemeV2 = (raw: Partial<ThemeV2Definition>): ThemeV2Defini
|
|||||||
tags: normalizeThemeTags(raw.tags),
|
tags: normalizeThemeTags(raw.tags),
|
||||||
workshopId: normalizeWorkshopId(raw.workshopId),
|
workshopId: normalizeWorkshopId(raw.workshopId),
|
||||||
nsfw: raw.nsfw === true,
|
nsfw: raw.nsfw === true,
|
||||||
|
allowForking: raw.allowForking !== false,
|
||||||
preview: text(raw.preview),
|
preview: text(raw.preview),
|
||||||
background,
|
background,
|
||||||
overlay: {
|
overlay: {
|
||||||
|
|||||||
@@ -524,6 +524,7 @@ export const reloadThemes = async () => {
|
|||||||
apiVersion: theme.apiVersion,
|
apiVersion: theme.apiVersion,
|
||||||
description: theme.description,
|
description: theme.description,
|
||||||
nsfw: theme.nsfw,
|
nsfw: theme.nsfw,
|
||||||
|
workshopId: theme.workshopId,
|
||||||
definition: theme.definition,
|
definition: theme.definition,
|
||||||
updateAvailable: theme.updateAvailable,
|
updateAvailable: theme.updateAvailable,
|
||||||
availableVersion: theme.availableVersion,
|
availableVersion: theme.availableVersion,
|
||||||
|
|||||||
+113
-7
@@ -4,6 +4,7 @@
|
|||||||
import { ezppfarm } from '$lib/api/ezpp';
|
import { ezppfarm } from '$lib/api/ezpp';
|
||||||
import {
|
import {
|
||||||
fetchCommunityThemeDetails,
|
fetchCommunityThemeDetails,
|
||||||
|
fetchCommunityThemes,
|
||||||
type CommunityTheme,
|
type CommunityTheme,
|
||||||
} from '$lib/communityThemes';
|
} from '$lib/communityThemes';
|
||||||
import DiagnosticsPanel from '$lib/components/diagnostics/DiagnosticsPanel.svelte';
|
import DiagnosticsPanel from '$lib/components/diagnostics/DiagnosticsPanel.svelte';
|
||||||
@@ -292,6 +293,7 @@
|
|||||||
let themeSearch = $state('');
|
let themeSearch = $state('');
|
||||||
let activeThemesTab = $state<'library' | 'workshop'>('library');
|
let activeThemesTab = $state<'library' | 'workshop'>('library');
|
||||||
let themeSavedUnlisten: (() => void) | undefined;
|
let themeSavedUnlisten: (() => void) | undefined;
|
||||||
|
let themePublishedUnlisten: (() => void) | undefined;
|
||||||
let refreshingThemes = $state(false);
|
let refreshingThemes = $state(false);
|
||||||
let reloadingActiveTheme = $state(false);
|
let reloadingActiveTheme = $state(false);
|
||||||
let revealedNsfw = $state<string[]>([]);
|
let revealedNsfw = $state<string[]>([]);
|
||||||
@@ -311,6 +313,10 @@
|
|||||||
let installedReportOpen = $state(false);
|
let installedReportOpen = $state(false);
|
||||||
let installedReportTarget = $state<CommunityTheme | null>(null);
|
let installedReportTarget = $state<CommunityTheme | null>(null);
|
||||||
let installedReportLoading = $state(false);
|
let installedReportLoading = $state(false);
|
||||||
|
let ownedWorkshopThemeIds = $state<string[]>([]);
|
||||||
|
let ownedWorkshopThemeNames = $state<string[]>([]);
|
||||||
|
let ownedWorkshopLoaded = $state(false);
|
||||||
|
let ownedThemesRequestId = 0;
|
||||||
let logViewerOpen = $state(false);
|
let logViewerOpen = $state(false);
|
||||||
let presenceEditorOpen = $state(false);
|
let presenceEditorOpen = $state(false);
|
||||||
|
|
||||||
@@ -348,11 +354,90 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const isOwnInstalledTheme = (theme: Theme) =>
|
const currentUserAliases = $derived.by(() => {
|
||||||
Boolean(
|
const aliases = new Set<string>();
|
||||||
$currentUser &&
|
const add = (value: string | undefined) => {
|
||||||
theme.author.trim().toLowerCase() === $currentUser.name.trim().toLowerCase()
|
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<string>();
|
||||||
|
const names = new Set<string>();
|
||||||
|
|
||||||
|
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) => {
|
const themeButtonLabel = (theme: Theme, activeFolder: string | undefined) => {
|
||||||
if (activeFolder === theme.folder_name) return 'Theme in use';
|
if (activeFolder === theme.folder_name) return 'Theme in use';
|
||||||
@@ -459,7 +544,7 @@
|
|||||||
|
|
||||||
installedReportLoading = true;
|
installedReportLoading = true;
|
||||||
try {
|
try {
|
||||||
const target = await fetchCommunityThemeDetails(theme.folder_name);
|
const target = await fetchCommunityThemeDetails(theme.workshopId ?? theme.folder_name);
|
||||||
if (target.isOwner) {
|
if (target.isOwner) {
|
||||||
sileo.error({
|
sileo.error({
|
||||||
title: 'You cannot report your own theme',
|
title: 'You cannot report your own theme',
|
||||||
@@ -1582,9 +1667,30 @@
|
|||||||
}
|
}
|
||||||
}).then((unlisten) => (themeSavedUnlisten = unlisten));
|
}).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 () => {
|
return () => {
|
||||||
window.clearInterval(animateInterval);
|
window.clearInterval(animateInterval);
|
||||||
themeSavedUnlisten?.();
|
themeSavedUnlisten?.();
|
||||||
|
themePublishedUnlisten?.();
|
||||||
stopThemeScript();
|
stopThemeScript();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -4042,7 +4148,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
{/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'}
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
Reference in new issue
Block a user