diff --git a/src/lib/communityThemes.ts b/src/lib/communityThemes.ts index 9e57b3c..74c4aae 100644 --- a/src/lib/communityThemes.ts +++ b/src/lib/communityThemes.ts @@ -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 & { 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 { + 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 { + 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/. */ diff --git a/src/lib/components/notifications/NotificationHub.svelte b/src/lib/components/notifications/NotificationHub.svelte index eb09525..ef7fba0 100644 --- a/src/lib/components/notifications/NotificationHub.svelte +++ b/src/lib/components/notifications/NotificationHub.svelte @@ -37,6 +37,8 @@ const NOTIFICATION_TYPE_LABELS: Record = { 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', diff --git a/src/lib/components/theme/CommunityWorkshopView.svelte b/src/lib/components/theme/CommunityWorkshopView.svelte index d58b12f..b964299 100644 --- a/src/lib/components/theme/CommunityWorkshopView.svelte +++ b/src/lib/components/theme/CommunityWorkshopView.svelte @@ -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([]); + let reviewTotal = $state(0); + let reviewActionId = $state(null); let selectedReportIds = $state([]); let reportActionId = $state(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} {/if} + {#if theme.reviewStatus === 'pending'} + + {#snippet trigger(props)} + + + In review + + {/snippet} + + {:else if theme.reviewStatus === 'rejected'} + + {#snippet trigger(props)} + + + Rejected + + {/snippet} + + {/if} {#if theme.isHidden} - Open reports and recent staff activity. + Themes awaiting review, open reports and recent staff activity.
@@ -2359,6 +2437,76 @@ Loading moderation data...
{:else} +
+

+ + Review queue + + {reviewTotal} + +

+ {#if reviewQueue.length === 0} +

+ No themes are waiting for review. +

+ {:else} +
+ {#each reviewQueue as theme (theme.id)} +
+
+

{theme.name}

+

+ by {theme.author.name} · v{theme.version} · + {formatModerationDate(theme.createdAt)} +

+ {#if theme.description} +

+ {theme.description} +

+ {/if} +
+
+ + +
+
+ {/each} +
+ {/if} +
+