feat(workshop): add the pre-publication review queue

New themes are submitted for review instead of appearing live, so the workshop now surfaces pending and rejected badges on the creator's own cards and adds a Review queue to the moderation panel with approve and reject actions. Publishing reports that the theme is awaiting review, and approval/rejection notifications are labelled.
This commit is contained in:
HorizonCode committed 2026-11-03 19:01:29 +01:00
1 parent 0025313c4d
commit 809fca6f29
4 files changed
+247 -5

No files matched your search

+90 -1
View File
@@ -32,6 +32,8 @@ export interface ThemeSearchMatch {
score: number;
}
export type ThemeReviewStatus = 'pending' | 'approved' | 'rejected';
export interface CommunityTheme {
id: string;
name: string;
@@ -45,6 +47,11 @@ export interface CommunityTheme {
likeCount: number;
tags: string[];
allowForking: boolean;
reviewStatus: ThemeReviewStatus;
reviewNote: string | null;
reviewedAt: string | null;
reviewedBy: number | null;
reviewedByName: string | null;
createdAt: string;
updatedAt: string;
author: CommunityThemeAuthor;
@@ -834,7 +841,7 @@ export async function publishThemeToWorkshop(options: {
const completeData = (await completeRes.json()) as Record<string, unknown> & {
themeId?: unknown;
};
onProgress('Theme published successfully!', 100);
onProgress('Upload complete', 100);
return {
success: true,
@@ -849,6 +856,88 @@ export async function publishThemeToWorkshop(options: {
}
}
export interface ThemeReviewQueueResponse {
items: CommunityTheme[];
total: number;
page: number;
totalPages: number;
canModerate: boolean;
}
/**
* Load the staff review queue. New themes stay out of public listings until approved.
*/
export async function fetchThemeReviewQueue(
status: ThemeReviewStatus = 'pending',
page = 1,
limit = 50
): Promise<ThemeReviewQueueResponse> {
const baseUrl = await getThemesServiceUrl();
const headers = await getAuthHeaders();
if (!headers['X-Username'] || !headers['X-Password']) {
throw new Error('Please log in to view the review queue.');
}
const url = new URL(`${baseUrl}/v1/themes/moderation/review`);
url.searchParams.set('status', status);
url.searchParams.set('page', String(page));
url.searchParams.set('limit', String(limit));
const res = await fetch(url.toString(), { headers });
if (!res.ok) {
const errorJson = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(errorJson.error || `Failed to load the review queue (HTTP ${res.status})`);
}
const data = (await res.json()) as ThemeReviewQueueResponse;
for (const item of data.items) {
if (item.previewUrl && !item.previewUrl.startsWith('http')) {
item.previewUrl = `${baseUrl}${item.previewUrl}`;
}
if (item.downloadUrl && !item.downloadUrl.startsWith('http')) {
item.downloadUrl = `${baseUrl}${item.downloadUrl}`;
}
}
return data;
}
/**
* Approve a pending theme so it becomes publicly listed, or reject it with a note.
*/
export async function reviewCommunityTheme(
themeId: string,
status: 'approved' | 'rejected',
note?: string
): Promise<CommunityTheme> {
const baseUrl = await getThemesServiceUrl();
const headers = await getAuthHeaders();
if (!headers['X-Username'] || !headers['X-Password']) {
throw new Error('Please log in to review themes.');
}
const res = await fetch(`${baseUrl}/v1/themes/${encodeURIComponent(themeId)}/review`, {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ status, note }),
});
if (!res.ok) {
const errorJson = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(errorJson.error || `Failed to review theme (HTTP ${res.status})`);
}
const item = (await res.json()) as CommunityTheme;
if (item.previewUrl && !item.previewUrl.startsWith('http')) {
item.previewUrl = `${baseUrl}${item.previewUrl}`;
}
if (item.downloadUrl && !item.downloadUrl.startsWith('http')) {
item.downloadUrl = `${baseUrl}${item.downloadUrl}`;
}
return item;
}
/**
* Download, extract, and register a community theme into ~/.ezpplauncher/themes/.
*/
@@ -37,6 +37,8 @@
const NOTIFICATION_TYPE_LABELS: Record<string, string> = {
theme_hidden: 'Theme hidden',
theme_restored: 'Theme restored',
theme_approved: 'Theme approved',
theme_rejected: 'Theme needs changes',
theme_taken_down: 'Theme taken down',
report_resolved: 'Report resolved',
report_dismissed: 'Report dismissed',
@@ -15,6 +15,8 @@
setCommunityThemeTags,
reportCommunityTheme,
fetchThemeReports,
fetchThemeReviewQueue,
reviewCommunityTheme,
resolveThemeReport,
bulkResolveThemeReports,
downloadThemeReportsCsv,
@@ -147,6 +149,9 @@
let moderationEventActor = $state('');
let moderationEventsPage = $state(1);
let moderationEventsTotalPages = $state(1);
let reviewQueue = $state<CommunityTheme[]>([]);
let reviewTotal = $state(0);
let reviewActionId = $state<string | null>(null);
let selectedReportIds = $state<number[]>([]);
let reportActionId = $state<number | null>(null);
let bulkAction = $state<'hide' | 'resolved' | 'dismissed' | null>(null);
@@ -211,6 +216,8 @@
report_dismissed: 'Report dismissed',
quarantine: 'Taken down',
quarantine_restore: 'Takedown lifted',
review_approved: 'Theme approved',
review_rejected: 'Theme rejected',
};
const AUDIT_ACTIONS = [
{ id: 'all', label: 'All actions' },
@@ -221,6 +228,8 @@
{ id: 'report', label: 'Reported' },
{ id: 'report_resolved', label: 'Report resolved' },
{ id: 'report_dismissed', label: 'Report dismissed' },
{ id: 'review_approved', label: 'Theme approved' },
{ id: 'review_rejected', label: 'Theme rejected' },
{ id: 'delete', label: 'Trashed' },
{ id: 'restore_deleted', label: 'Restored from trash' },
{ id: 'delete_permanent', label: 'Permanently deleted' },
@@ -918,11 +927,14 @@
moderationLoading = true;
moderationError = null;
try {
const [reportsResult, eventsResult] = await Promise.all([
const [reportsResult, eventsResult, reviewResult] = await Promise.all([
fetchThemeReports({ ...currentReportFilters(), page, limit: 50 }),
fetchModerationEvents({ ...currentEventFilters(), page: 1, limit: 50 }),
fetchThemeReviewQueue('pending', 1, 50),
]);
moderationReports = reportsResult.reports;
reviewQueue = reviewResult.items;
reviewTotal = reviewResult.total;
moderationTotal = reportsResult.total;
moderationPage = reportsResult.page;
moderationTotalPages = reportsResult.totalPages;
@@ -949,6 +961,41 @@
void loadModerationPanel(1);
}
async function handleReviewTheme(theme: CommunityTheme, status: 'approved' | 'rejected') {
if (reviewActionId) return;
let note: string | undefined;
if (status === 'rejected') {
const answer = window.prompt(
`Reject "${theme.name}"? Add an optional note for the creator:`,
''
);
if (answer === null) return;
note = answer.trim() || undefined;
}
reviewActionId = theme.id;
try {
await reviewCommunityTheme(theme.id, status, note);
sileo.success({
title: status === 'approved' ? 'Theme approved' : 'Theme rejected',
description:
status === 'approved'
? `"${theme.name}" is now listed in the Community Workshop.`
: `"${theme.name}" stays hidden until the creator republishes it.`,
});
await loadModerationPanel(moderationPage);
void loadWorkshopThemes();
} catch (err) {
sileo.error({
title: 'Could not review theme',
description: err instanceof Error ? err.message : String(err),
});
} finally {
reviewActionId = null;
}
}
function handleAuditFilterChange() {
moderationEventsPage = 1;
selectedReportIds = [];
@@ -1616,6 +1663,37 @@
{/snippet}
</WorkshopTooltip>
{/if}
{#if theme.reviewStatus === 'pending'}
<WorkshopTooltip
content="Waiting for staff review before it is listed publicly"
>
{#snippet trigger(props)}
<span
{...props}
tabindex="-1"
class="flex items-center gap-1 rounded-md border border-amber-500/40 bg-amber-950/85 px-1.5 py-0.5 text-[9px] font-medium text-amber-200 backdrop-blur-sm"
>
<LoaderCircle class="size-3" />
In review
</span>
{/snippet}
</WorkshopTooltip>
{:else if theme.reviewStatus === 'rejected'}
<WorkshopTooltip
content={`Not approved${theme.reviewNote ? `: ${theme.reviewNote}` : ''}`}
>
{#snippet trigger(props)}
<span
{...props}
tabindex="-1"
class="flex items-center gap-1 rounded-md border border-red-500/40 bg-red-950/85 px-1.5 py-0.5 text-[9px] font-medium text-red-200 backdrop-blur-sm"
>
<X class="size-3" />
Rejected
</span>
{/snippet}
</WorkshopTooltip>
{/if}
{#if theme.isHidden}
<WorkshopTooltip
content={`${theme.hiddenReason || 'Hidden by a moderator'}${
@@ -2145,7 +2223,7 @@
Moderation
</Dialog.Title>
<Dialog.Description class="mt-1 text-[11px] text-muted-foreground">
Open reports and recent staff activity.
Themes awaiting review, open reports and recent staff activity.
</Dialog.Description>
</div>
<div class="flex items-center gap-1">
@@ -2359,6 +2437,76 @@
<LoaderCircle class="size-4 animate-spin text-primary" /> Loading moderation data...
</div>
{:else}
<section>
<h3
class="flex items-center gap-2 text-[11px] font-semibold tracking-wide text-white/80 uppercase"
>
<ShieldCheck class="size-3.5 text-cyan-300" />
Review queue
<Badge
variant="secondary"
class="bg-theme-800 px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground"
>
{reviewTotal}
</Badge>
</h3>
{#if reviewQueue.length === 0}
<p
class="mt-2 rounded-lg border border-theme-800/60 bg-theme-900/20 p-3 text-[11px] text-muted-foreground"
>
No themes are waiting for review.
</p>
{:else}
<div class="mt-2 space-y-2">
{#each reviewQueue as theme (theme.id)}
<div
class="flex items-start justify-between gap-3 rounded-lg border border-theme-800/70 bg-theme-900/30 p-3"
>
<div class="min-w-0">
<p class="truncate text-xs font-semibold text-white">{theme.name}</p>
<p class="mt-0.5 text-[10px] text-muted-foreground">
by {theme.author.name} · v{theme.version} ·
{formatModerationDate(theme.createdAt)}
</p>
{#if theme.description}
<p
class="mt-1 line-clamp-2 text-[10px] leading-relaxed text-muted-foreground"
>
{theme.description}
</p>
{/if}
</div>
<div class="flex shrink-0 items-center gap-1.5">
<Button
size="sm"
class="h-7 gap-1 text-xs"
onclick={() => void handleReviewTheme(theme, 'approved')}
disabled={reviewActionId === theme.id}
>
{#if reviewActionId === theme.id}
<LoaderCircle class="size-3 animate-spin" />
{:else}
<Check class="size-3" />
{/if}
Approve
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 gap-1 text-xs text-red-300 hover:bg-red-500/10 hover:text-red-200"
onclick={() => void handleReviewTheme(theme, 'rejected')}
disabled={reviewActionId === theme.id}
>
<X class="size-3" />
Reject
</Button>
</div>
</div>
{/each}
</div>
{/if}
</section>
<section>
<h3
class="flex items-center gap-2 text-[11px] font-semibold tracking-wide text-white/80 uppercase"
@@ -3404,6 +3404,7 @@
if (!savedFolder) return;
}
const firstPublish = !definition.workshopId;
publishing = true;
publishProgress = 5;
publishStatusMessage = 'Packaging theme archive...';
@@ -3452,8 +3453,10 @@
}
sileo.success({
title: 'Published to Workshop!',
description: `"${definition.name}" is now live in the Community Themes Workshop.`,
title: firstPublish ? 'Submitted for review' : 'Workshop update submitted',
description: firstPublish
? `"${definition.name}" will be listed once a moderator approves it.`
: `"${definition.name}" was updated in the Community Workshop.`,
});
publishModalOpen = false;