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:
1 parent
0025313c4d
commit
809fca6f29
4 files changed
+247
-5
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user