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;
|
score: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ThemeReviewStatus = 'pending' | 'approved' | 'rejected';
|
||||||
|
|
||||||
export interface CommunityTheme {
|
export interface CommunityTheme {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -45,6 +47,11 @@ export interface CommunityTheme {
|
|||||||
likeCount: number;
|
likeCount: number;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
allowForking: boolean;
|
allowForking: boolean;
|
||||||
|
reviewStatus: ThemeReviewStatus;
|
||||||
|
reviewNote: string | null;
|
||||||
|
reviewedAt: string | null;
|
||||||
|
reviewedBy: number | null;
|
||||||
|
reviewedByName: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
author: CommunityThemeAuthor;
|
author: CommunityThemeAuthor;
|
||||||
@@ -834,7 +841,7 @@ export async function publishThemeToWorkshop(options: {
|
|||||||
const completeData = (await completeRes.json()) as Record<string, unknown> & {
|
const completeData = (await completeRes.json()) as Record<string, unknown> & {
|
||||||
themeId?: unknown;
|
themeId?: unknown;
|
||||||
};
|
};
|
||||||
onProgress('Theme published successfully!', 100);
|
onProgress('Upload complete', 100);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
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/.
|
* Download, extract, and register a community theme into ~/.ezpplauncher/themes/.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -37,6 +37,8 @@
|
|||||||
const NOTIFICATION_TYPE_LABELS: Record<string, string> = {
|
const NOTIFICATION_TYPE_LABELS: Record<string, string> = {
|
||||||
theme_hidden: 'Theme hidden',
|
theme_hidden: 'Theme hidden',
|
||||||
theme_restored: 'Theme restored',
|
theme_restored: 'Theme restored',
|
||||||
|
theme_approved: 'Theme approved',
|
||||||
|
theme_rejected: 'Theme needs changes',
|
||||||
theme_taken_down: 'Theme taken down',
|
theme_taken_down: 'Theme taken down',
|
||||||
report_resolved: 'Report resolved',
|
report_resolved: 'Report resolved',
|
||||||
report_dismissed: 'Report dismissed',
|
report_dismissed: 'Report dismissed',
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
setCommunityThemeTags,
|
setCommunityThemeTags,
|
||||||
reportCommunityTheme,
|
reportCommunityTheme,
|
||||||
fetchThemeReports,
|
fetchThemeReports,
|
||||||
|
fetchThemeReviewQueue,
|
||||||
|
reviewCommunityTheme,
|
||||||
resolveThemeReport,
|
resolveThemeReport,
|
||||||
bulkResolveThemeReports,
|
bulkResolveThemeReports,
|
||||||
downloadThemeReportsCsv,
|
downloadThemeReportsCsv,
|
||||||
@@ -147,6 +149,9 @@
|
|||||||
let moderationEventActor = $state('');
|
let moderationEventActor = $state('');
|
||||||
let moderationEventsPage = $state(1);
|
let moderationEventsPage = $state(1);
|
||||||
let moderationEventsTotalPages = $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 selectedReportIds = $state<number[]>([]);
|
||||||
let reportActionId = $state<number | null>(null);
|
let reportActionId = $state<number | null>(null);
|
||||||
let bulkAction = $state<'hide' | 'resolved' | 'dismissed' | null>(null);
|
let bulkAction = $state<'hide' | 'resolved' | 'dismissed' | null>(null);
|
||||||
@@ -211,6 +216,8 @@
|
|||||||
report_dismissed: 'Report dismissed',
|
report_dismissed: 'Report dismissed',
|
||||||
quarantine: 'Taken down',
|
quarantine: 'Taken down',
|
||||||
quarantine_restore: 'Takedown lifted',
|
quarantine_restore: 'Takedown lifted',
|
||||||
|
review_approved: 'Theme approved',
|
||||||
|
review_rejected: 'Theme rejected',
|
||||||
};
|
};
|
||||||
const AUDIT_ACTIONS = [
|
const AUDIT_ACTIONS = [
|
||||||
{ id: 'all', label: 'All actions' },
|
{ id: 'all', label: 'All actions' },
|
||||||
@@ -221,6 +228,8 @@
|
|||||||
{ id: 'report', label: 'Reported' },
|
{ id: 'report', label: 'Reported' },
|
||||||
{ id: 'report_resolved', label: 'Report resolved' },
|
{ id: 'report_resolved', label: 'Report resolved' },
|
||||||
{ id: 'report_dismissed', label: 'Report dismissed' },
|
{ id: 'report_dismissed', label: 'Report dismissed' },
|
||||||
|
{ id: 'review_approved', label: 'Theme approved' },
|
||||||
|
{ id: 'review_rejected', label: 'Theme rejected' },
|
||||||
{ id: 'delete', label: 'Trashed' },
|
{ id: 'delete', label: 'Trashed' },
|
||||||
{ id: 'restore_deleted', label: 'Restored from trash' },
|
{ id: 'restore_deleted', label: 'Restored from trash' },
|
||||||
{ id: 'delete_permanent', label: 'Permanently deleted' },
|
{ id: 'delete_permanent', label: 'Permanently deleted' },
|
||||||
@@ -918,11 +927,14 @@
|
|||||||
moderationLoading = true;
|
moderationLoading = true;
|
||||||
moderationError = null;
|
moderationError = null;
|
||||||
try {
|
try {
|
||||||
const [reportsResult, eventsResult] = await Promise.all([
|
const [reportsResult, eventsResult, reviewResult] = await Promise.all([
|
||||||
fetchThemeReports({ ...currentReportFilters(), page, limit: 50 }),
|
fetchThemeReports({ ...currentReportFilters(), page, limit: 50 }),
|
||||||
fetchModerationEvents({ ...currentEventFilters(), page: 1, limit: 50 }),
|
fetchModerationEvents({ ...currentEventFilters(), page: 1, limit: 50 }),
|
||||||
|
fetchThemeReviewQueue('pending', 1, 50),
|
||||||
]);
|
]);
|
||||||
moderationReports = reportsResult.reports;
|
moderationReports = reportsResult.reports;
|
||||||
|
reviewQueue = reviewResult.items;
|
||||||
|
reviewTotal = reviewResult.total;
|
||||||
moderationTotal = reportsResult.total;
|
moderationTotal = reportsResult.total;
|
||||||
moderationPage = reportsResult.page;
|
moderationPage = reportsResult.page;
|
||||||
moderationTotalPages = reportsResult.totalPages;
|
moderationTotalPages = reportsResult.totalPages;
|
||||||
@@ -949,6 +961,41 @@
|
|||||||
void loadModerationPanel(1);
|
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() {
|
function handleAuditFilterChange() {
|
||||||
moderationEventsPage = 1;
|
moderationEventsPage = 1;
|
||||||
selectedReportIds = [];
|
selectedReportIds = [];
|
||||||
@@ -1616,6 +1663,37 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</WorkshopTooltip>
|
</WorkshopTooltip>
|
||||||
{/if}
|
{/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}
|
{#if theme.isHidden}
|
||||||
<WorkshopTooltip
|
<WorkshopTooltip
|
||||||
content={`${theme.hiddenReason || 'Hidden by a moderator'}${
|
content={`${theme.hiddenReason || 'Hidden by a moderator'}${
|
||||||
@@ -2145,7 +2223,7 @@
|
|||||||
Moderation
|
Moderation
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<Dialog.Description class="mt-1 text-[11px] text-muted-foreground">
|
<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>
|
</Dialog.Description>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
@@ -2359,6 +2437,76 @@
|
|||||||
<LoaderCircle class="size-4 animate-spin text-primary" /> Loading moderation data...
|
<LoaderCircle class="size-4 animate-spin text-primary" /> Loading moderation data...
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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>
|
<section>
|
||||||
<h3
|
<h3
|
||||||
class="flex items-center gap-2 text-[11px] font-semibold tracking-wide text-white/80 uppercase"
|
class="flex items-center gap-2 text-[11px] font-semibold tracking-wide text-white/80 uppercase"
|
||||||
|
|||||||
@@ -3404,6 +3404,7 @@
|
|||||||
if (!savedFolder) return;
|
if (!savedFolder) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const firstPublish = !definition.workshopId;
|
||||||
publishing = true;
|
publishing = true;
|
||||||
publishProgress = 5;
|
publishProgress = 5;
|
||||||
publishStatusMessage = 'Packaging theme archive...';
|
publishStatusMessage = 'Packaging theme archive...';
|
||||||
@@ -3452,8 +3453,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
sileo.success({
|
sileo.success({
|
||||||
title: 'Published to Workshop!',
|
title: firstPublish ? 'Submitted for review' : 'Workshop update submitted',
|
||||||
description: `"${definition.name}" is now live in the Community Themes Workshop.`,
|
description: firstPublish
|
||||||
|
? `"${definition.name}" will be listed once a moderator approves it.`
|
||||||
|
: `"${definition.name}" was updated in the Community Workshop.`,
|
||||||
});
|
});
|
||||||
|
|
||||||
publishModalOpen = false;
|
publishModalOpen = false;
|
||||||
|
|||||||
Reference in new issue
Block a user