From 9b4158410d840fba30bc7ff7d16b3446d4637b4c Mon Sep 17 00:00:00 2001 From: HorizonCode Date: Tue, 3 Nov 2026 10:03:53 +0100 Subject: [PATCH] feat(workshop): add moderation, search, tag, and tooltip controls Surface staff reports, takedowns, announcements, audit tools, ranked search, creator tag editing, and shared tooltips in the workshop. --- THEME_API_V2.md | 4 +- eslint.config.js | 1 + src/lib/communityThemes.ts | 738 +++-- .../notifications/NotificationHub.svelte | 303 +++ .../theme/CommunityWorkshopView.svelte | 2421 ++++++++++++++++- .../components/theme/WorkshopTooltip.svelte | 29 + src/lib/notifications.ts | 100 + src/screens/Launch.svelte | 5 +- 8 files changed, 3258 insertions(+), 343 deletions(-) create mode 100644 src/lib/components/notifications/NotificationHub.svelte create mode 100644 src/lib/components/theme/WorkshopTooltip.svelte create mode 100644 src/lib/notifications.ts diff --git a/THEME_API_V2.md b/THEME_API_V2.md index 92ad8f3..38f7080 100644 --- a/THEME_API_V2.md +++ b/THEME_API_V2.md @@ -7,7 +7,9 @@ editor can create and edit these themes without writing code. Themes can opt int custom **Svelte layer** when they need behavior that data alone cannot express; that layer runs theme-authored JavaScript and therefore needs an explicit trust prompt. -API 1.0 themes continue to work unchanged. Pick the API that fits the theme: +API 1.0 themes still work when they are already installed or installed manually from a +`.ezpplauncher-theme` file, but they can no longer be uploaded to the Community Themes +Workshop. Workshop uploads accept API 2.0 only. Pick the API that fits the theme: | | API 1.0 | API 2.0 | | ----------------------- | ------------------------------------------------- | ---------------------------------------------- | diff --git a/eslint.config.js b/eslint.config.js index 07af029..4e0aa30 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -44,6 +44,7 @@ export default ts.config( { rules: { 'no-empty': 'off', + '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_', varsIgnorePattern: '^\\$\\$(Props|Events|Slots|Generic)$' }, diff --git a/src/lib/communityThemes.ts b/src/lib/communityThemes.ts index 56d62ed..1fb98c5 100644 --- a/src/lib/communityThemes.ts +++ b/src/lib/communityThemes.ts @@ -12,6 +12,20 @@ export interface CommunityThemeAuthor { avatarUrl: string; } +export interface CommunityThemeForkOrigin { + id: string; + name: string; + authorName: string; +} + +export type ThemeSearchField = 'id' | 'name' | 'tag' | 'author' | 'description'; + +export interface ThemeSearchMatch { + field: ThemeSearchField; + value: string; + score: number; +} + export interface CommunityTheme { id: string; name: string; @@ -28,8 +42,22 @@ export interface CommunityTheme { updatedAt: string; author: CommunityThemeAuthor; isLiked: boolean; + isOwner: boolean; + isHidden: boolean; + hiddenReason: string | null; + hiddenAt: string | null; + hiddenBy: number | null; + hiddenByName: string | null; + isQuarantined: boolean; + quarantineReason: string | null; + quarantinedAt: string | null; + quarantinedBy: number | null; + quarantinedByName: string | null; + deletedAt: string | null; + forkedFrom: CommunityThemeForkOrigin | null; previewUrl: string; downloadUrl: string; + searchMatch?: ThemeSearchMatch | null; } export interface CommunityThemesResponse { @@ -37,154 +65,83 @@ export interface CommunityThemesResponse { total: number; page: number; totalPages: number; + canModerate: boolean; } -export const LEGACY_COMMUNITY_THEMES: CommunityTheme[] = [ - { - id: 'execution_clap', - name: 'Execution Clap', - description: '', - version: '1.0.0', - apiVersion: '1.0', - hasExecutableCode: true, - fileSize: 59213509, - checksumSha256: '', - downloadCount: 142, - likeCount: 28, - tags: ['legacy', 'apiv1', 'video', 'anime'], - createdAt: '2025-01-01T00:00:00.000Z', - updatedAt: '2025-01-01T00:00:00.000Z', - author: { - id: 1001, - name: 'HorizonCode', - donor: true, - avatarUrl: 'https://a.ez-pp.farm/1001', - }, - isLiked: false, - previewUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/execution_clap.png', - downloadUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/execution_clap.ezpplauncher-theme', - }, - { - id: 'dance', - name: 'Dance', - description: '', - version: '1.0.0', - apiVersion: '1.0', - hasExecutableCode: true, - fileSize: 21956554, - checksumSha256: '', - downloadCount: 98, - likeCount: 19, - tags: ['legacy', 'apiv1', 'video', 'anime'], - createdAt: '2025-01-01T00:00:00.000Z', - updatedAt: '2025-01-01T00:00:00.000Z', - author: { - id: 1001, - name: 'HorizonCode', - donor: true, - avatarUrl: 'https://a.ez-pp.farm/1001', - }, - isLiked: false, - previewUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/dance.png', - downloadUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/dance.ezpplauncher-theme', - }, - { - id: 'lagtrain', - name: 'Lagtrain', - description: '', - version: '1.1.0', - apiVersion: '1.0', - hasExecutableCode: true, - fileSize: 45781440, - checksumSha256: '', - downloadCount: 230, - likeCount: 54, - tags: ['legacy', 'apiv1', 'video', 'anime'], - createdAt: '2025-01-01T00:00:00.000Z', - updatedAt: '2025-01-01T00:00:00.000Z', - author: { - id: 1001, - name: 'HorizonCode', - donor: true, - avatarUrl: 'https://a.ez-pp.farm/1001', - }, - isLiked: false, - previewUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/lagtrain.png', - downloadUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/lagtrain.ezpplauncher-theme', - }, - { - id: 'looping_the_rooms', - name: 'Looping the Rooms', - description: '', - version: '1.0.0', - apiVersion: '1.0', - hasExecutableCode: true, - fileSize: 14507000, - checksumSha256: '', - downloadCount: 76, - likeCount: 14, - tags: ['legacy', 'apiv1', 'video'], - createdAt: '2025-01-01T00:00:00.000Z', - updatedAt: '2025-01-01T00:00:00.000Z', - author: { - id: 1001, - name: 'HorizonCode', - donor: true, - avatarUrl: 'https://a.ez-pp.farm/1001', - }, - isLiked: false, - previewUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/looping_the_rooms.png', - downloadUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/looping_the_rooms.ezpplauncher-theme', - }, - { - id: 'daidaidaidaidaikirai', - name: 'Daidaidaidaidaikirai', - description: '', - version: '1.1.0', - apiVersion: '1.0', - hasExecutableCode: true, - fileSize: 19800000, - checksumSha256: '', - downloadCount: 185, - likeCount: 41, - tags: ['legacy', 'apiv1', 'video', 'anime'], - createdAt: '2025-01-01T00:00:00.000Z', - updatedAt: '2025-01-01T00:00:00.000Z', - author: { - id: 1001, - name: 'HorizonCode', - donor: true, - avatarUrl: 'https://a.ez-pp.farm/1001', - }, - isLiked: false, - previewUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/daidaidaidaidaikirai.png', - downloadUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/daidaidaidaidaikirai.ezpplauncher-theme', - }, - { - id: 'teto_the_31st', - name: 'Teto the 31st', - description: '', - version: '1.1.0', - apiVersion: '1.0', - hasExecutableCode: true, - fileSize: 23400000, - checksumSha256: '', - downloadCount: 310, - likeCount: 82, - tags: ['legacy', 'apiv1', 'video', 'anime'], - createdAt: '2025-01-01T00:00:00.000Z', - updatedAt: '2025-01-01T00:00:00.000Z', - author: { - id: 1001, - name: 'HorizonCode', - donor: true, - avatarUrl: 'https://a.ez-pp.farm/1001', - }, - isLiked: false, - previewUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/teto_the_31st.png', - downloadUrl: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/teto_the_31st.ezpplauncher-theme', - }, -]; +export type ThemeReportStatus = 'open' | 'resolved' | 'dismissed'; +export type ThemeReportCategory = + | 'spam' + | 'harassment' + | 'malware' + | 'copyright' + | 'broken' + | 'other'; + +export interface ThemeReport { + id: number; + themeId: string; + themeName: string; + themeAuthorName: string; + themeHidden: boolean; + themeDeleted: boolean; + themeQuarantined: boolean; + reporterId: number; + reporterName: string; + category: ThemeReportCategory; + reason: string; + status: ThemeReportStatus; + createdAt: string; + resolvedAt: string | null; + resolvedBy: number | null; + resolvedByName: string | null; + resolutionNote: string | null; +} + +export interface ThemeReportQuery { + status?: ThemeReportStatus | 'all'; + q?: string; + category?: ThemeReportCategory | 'all'; + author?: string; + from?: string; + to?: string; + page?: number; + limit?: number; +} + +export interface ModerationEvent { + id: number; + themeId: string; + themeName: string; + actorId: number; + actorName: string; + action: string; + reason: string | null; + createdAt: string; +} + +export interface ModerationEventQuery { + themeId?: string; + action?: string; + actor?: string; + from?: string; + to?: string; + page?: number; + limit?: number; +} + +export interface ModerationEventsResponse { + events: ModerationEvent[]; + total: number; + page: number; + totalPages: number; +} + +export interface ThemeReportsResponse { + reports: ThemeReport[]; + total: number; + page: number; + totalPages: number; +} const DEFAULT_THEMES_SERVICE_URL = 'https://themes.ez-pp.farm'; @@ -213,104 +170,72 @@ export async function fetchCommunityThemes(options?: { q?: string; tag?: string; authorId?: number; - sort?: 'popular' | 'recent' | 'likes' | 'downloads'; + sort?: 'relevance' | 'popular' | 'recent' | 'likes' | 'downloads'; page?: number; limit?: number; + hidden?: boolean; + quarantined?: boolean; + trashed?: boolean; }): Promise { - let remoteData: CommunityThemesResponse | null = null; - let remoteError: Error | null = null; + const baseUrl = await getThemesServiceUrl(); + const headers = await getAuthHeaders(); - try { - const baseUrl = await getThemesServiceUrl(); - const headers = await getAuthHeaders(); + const url = new URL(`${baseUrl}/v1/themes`); + if (options?.q) url.searchParams.set('q', options.q); + if (options?.tag) url.searchParams.set('tag', options.tag); + if (options?.authorId) url.searchParams.set('authorId', String(options.authorId)); + if (options?.sort) url.searchParams.set('sort', options.sort); + if (options?.page) url.searchParams.set('page', String(options.page)); + if (options?.limit) url.searchParams.set('limit', String(options.limit)); + if (options?.hidden) url.searchParams.set('hidden', '1'); + if (options?.quarantined) url.searchParams.set('quarantined', '1'); + if (options?.trashed) url.searchParams.set('trashed', '1'); - const url = new URL(`${baseUrl}/v1/themes`); - if (options?.q) url.searchParams.set('q', options.q); - if (options?.tag) url.searchParams.set('tag', options.tag); - if (options?.authorId) url.searchParams.set('authorId', String(options.authorId)); - if (options?.sort) url.searchParams.set('sort', options.sort); - if (options?.page) url.searchParams.set('page', String(options.page)); - if (options?.limit) url.searchParams.set('limit', String(options.limit)); - - const res = await fetch(url.toString(), { headers }); - if (res.ok) { - remoteData = (await res.json()) as CommunityThemesResponse; - for (const item of remoteData.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}`; - } - } - } else { - remoteError = new Error(`Failed to load themes (HTTP ${res.status})`); - } - } catch (err) { - remoteError = err instanceof Error ? err : new Error(String(err)); - console.warn('[CommunityThemes] Microservice unavailable, falling back to legacy catalogue:', err); + 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 themes (HTTP ${res.status})`); } - // Filter legacy themes based on options - const matchingLegacy = LEGACY_COMMUNITY_THEMES.filter((item) => { - if (options?.authorId && options.authorId !== item.author.id) return false; - if (options?.tag && options.tag !== 'all' && !item.tags.includes(options.tag.toLowerCase())) return false; - if (options?.q && options.q.trim().length > 0) { - const q = options.q.trim().toLowerCase(); - const text = `${item.name} ${item.description} ${item.id}`.toLowerCase(); - if (!text.includes(q)) return false; + const remoteData = (await res.json()) as CommunityThemesResponse; + for (const item of remoteData.items) { + if (item.previewUrl && !item.previewUrl.startsWith('http')) { + item.previewUrl = `${baseUrl}${item.previewUrl}`; } - return true; - }); - - if (!remoteData) { - if (matchingLegacy.length > 0 || !remoteError) { - return { - items: matchingLegacy, - total: matchingLegacy.length, - page: 1, - totalPages: 1, - }; + if (item.downloadUrl && !item.downloadUrl.startsWith('http')) { + item.downloadUrl = `${baseUrl}${item.downloadUrl}`; } - throw remoteError; } - // If remoteData returned, merge legacy themes if not already present in the list - const existingIds = new Set(remoteData.items.map((i) => i.id.toLowerCase())); - const missingLegacy = matchingLegacy.filter((l) => !existingIds.has(l.id.toLowerCase())); - - const allItems = [...remoteData.items, ...missingLegacy]; return { - items: allItems, - total: remoteData.total + missingLegacy.length, - page: remoteData.page, - totalPages: Math.ceil((remoteData.total + missingLegacy.length) / (options?.limit || 24)) || 1, + items: remoteData.items, + total: remoteData.total, + page: remoteData.page || 1, + totalPages: Number.isFinite(remoteData.totalPages) + ? Math.max(1, remoteData.totalPages) + : Math.ceil(Number(remoteData.total || 0) / Math.max(1, options?.limit || 24)) || 1, + canModerate: remoteData.canModerate ?? false, }; } export async function fetchCommunityThemeDetails(themeId: string): Promise { const cleanId = themeId.trim().toLowerCase(); - try { - const baseUrl = await getThemesServiceUrl(); - const headers = await getAuthHeaders(); + const baseUrl = await getThemesServiceUrl(); + const headers = await getAuthHeaders(); + const res = await fetch(`${baseUrl}/v1/themes/${encodeURIComponent(cleanId)}`, { headers }); + if (!res.ok) { + const errorJson = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(errorJson.error || 'Theme not found.'); + } - const res = await fetch(`${baseUrl}/v1/themes/${encodeURIComponent(cleanId)}`, { headers }); - if (res.ok) { - 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; - } - } catch {} - - const legacy = LEGACY_COMMUNITY_THEMES.find((t) => t.id === cleanId); - if (legacy) return legacy; - - throw new Error('Theme not found.'); + 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; } export async function toggleCommunityThemeLike(themeId: string): Promise<{ liked: boolean; likeCount: number }> { @@ -334,6 +259,34 @@ export async function toggleCommunityThemeLike(themeId: string): Promise<{ liked return (await res.json()) as { liked: boolean; likeCount: number }; } +export async function forkCommunityTheme(themeId: string): Promise { + const baseUrl = await getThemesServiceUrl(); + const headers = await getAuthHeaders(); + + if (!headers['X-Username'] || !headers['X-Password']) { + throw new Error('Please log in to fork community themes.'); + } + + const res = await fetch(`${baseUrl}/v1/themes/${encodeURIComponent(themeId)}/fork`, { + method: 'POST', + headers, + }); + + if (!res.ok) { + const errorJson = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(errorJson.error || `Failed to fork 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; +} + export async function deleteCommunityTheme(themeId: string): Promise { const baseUrl = await getThemesServiceUrl(); const headers = await getAuthHeaders(); @@ -353,6 +306,337 @@ export async function deleteCommunityTheme(themeId: string): Promise { } } +export async function reportCommunityTheme( + themeId: string, + reason: string, + category: ThemeReportCategory = 'other' +): Promise { + const baseUrl = await getThemesServiceUrl(); + const headers = await getAuthHeaders(); + + if (!headers['X-Username'] || !headers['X-Password']) { + throw new Error('Please log in to report community themes.'); + } + + const res = await fetch(`${baseUrl}/v1/themes/${encodeURIComponent(themeId)}/report`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason, category }), + }); + + if (!res.ok) { + const errorJson = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(errorJson.error || `Failed to report theme (HTTP ${res.status})`); + } +} + +export async function restoreCommunityTheme(themeId: string): Promise { + const baseUrl = await getThemesServiceUrl(); + const headers = await getAuthHeaders(); + + if (!headers['X-Username'] || !headers['X-Password']) { + throw new Error('Please log in to restore community themes.'); + } + + const res = await fetch(`${baseUrl}/v1/themes/${encodeURIComponent(themeId)}/restore`, { + method: 'POST', + headers, + }); + + if (!res.ok) { + const errorJson = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(errorJson.error || `Failed to restore 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; +} + +export async function fetchThemeReports( + options: ThemeReportQuery = {} +): Promise { + const baseUrl = await getThemesServiceUrl(); + const headers = await getAuthHeaders(); + + if (!headers['X-Username'] || !headers['X-Password']) { + throw new Error('Please log in to view theme reports.'); + } + + const url = new URL(`${baseUrl}/v1/themes/moderation/reports`); + url.searchParams.set('status', options.status ?? 'open'); + if (options.q?.trim()) url.searchParams.set('q', options.q.trim()); + if (options.category && options.category !== 'all') { + url.searchParams.set('category', options.category); + } + if (options.author?.trim()) url.searchParams.set('author', options.author.trim()); + if (options.from) url.searchParams.set('from', options.from); + if (options.to) url.searchParams.set('to', options.to); + if (options.page) url.searchParams.set('page', String(options.page)); + if (options.limit) url.searchParams.set('limit', String(options.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 reports (HTTP ${res.status})`); + } + return (await res.json()) as ThemeReportsResponse; +} + +export async function resolveThemeReport( + reportId: number, + status: 'resolved' | 'dismissed', + 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 manage theme reports.'); + } + + const res = await fetch(`${baseUrl}/v1/themes/moderation/reports/${reportId}`, { + 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 update report (HTTP ${res.status})`); + } +} + +export async function bulkResolveThemeReports( + reportIds: number[], + action: 'hide' | 'resolved' | 'dismissed', + note?: string +): Promise<{ updated: number; themes: number }> { + const baseUrl = await getThemesServiceUrl(); + const headers = await getAuthHeaders(); + + if (!headers['X-Username'] || !headers['X-Password']) { + throw new Error('Please log in to manage theme reports.'); + } + + const res = await fetch(`${baseUrl}/v1/themes/moderation/reports/bulk`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ reportIds, action, note }), + }); + + if (!res.ok) { + const errorJson = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(errorJson.error || `Failed to update reports (HTTP ${res.status})`); + } + return (await res.json()) as { updated: number; themes: number }; +} + +export async function downloadThemeReportsCsv(options: ThemeReportQuery = {}): Promise { + const baseUrl = await getThemesServiceUrl(); + const headers = await getAuthHeaders(); + + if (!headers['X-Username'] || !headers['X-Password']) { + throw new Error('Please log in to export theme reports.'); + } + + const url = new URL(`${baseUrl}/v1/themes/moderation/reports/export`); + url.searchParams.set('status', options.status ?? 'open'); + if (options.q?.trim()) url.searchParams.set('q', options.q.trim()); + if (options.category && options.category !== 'all') { + url.searchParams.set('category', options.category); + } + if (options.author?.trim()) url.searchParams.set('author', options.author.trim()); + if (options.from) url.searchParams.set('from', options.from); + if (options.to) url.searchParams.set('to', options.to); + + 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 export reports (HTTP ${res.status})`); + } + + const truncated = res.headers.get('x-export-truncated') === 'true'; + const blob = await res.blob(); + const disposition = res.headers.get('content-disposition') ?? ''; + const filename = /filename="([^"]+)"/.exec(disposition)?.[1] ?? 'theme-reports.csv'; + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); + return truncated; +} + +export async function setCommunityThemeQuarantine( + themeId: string, + quarantined: boolean, + reason?: string +): Promise { + const baseUrl = await getThemesServiceUrl(); + const headers = await getAuthHeaders(); + + if (!headers['X-Username'] || !headers['X-Password']) { + throw new Error('Please log in to moderate community themes.'); + } + + const res = await fetch(`${baseUrl}/v1/themes/${encodeURIComponent(themeId)}/quarantine`, { + method: 'PATCH', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ quarantined, reason: quarantined ? reason : undefined }), + }); + + if (!res.ok) { + const errorJson = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(errorJson.error || `Failed to update theme takedown (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; +} + +export async function fetchModerationEvents( + options: ModerationEventQuery = {} +): Promise { + const baseUrl = await getThemesServiceUrl(); + const headers = await getAuthHeaders(); + + if (!headers['X-Username'] || !headers['X-Password']) { + throw new Error('Please log in to view moderation history.'); + } + + const url = new URL(`${baseUrl}/v1/themes/moderation/events`); + if (options.themeId) url.searchParams.set('themeId', options.themeId); + if (options.action && options.action !== 'all') url.searchParams.set('action', options.action); + if (options.actor?.trim()) url.searchParams.set('actor', options.actor.trim()); + if (options.from) url.searchParams.set('from', options.from); + if (options.to) url.searchParams.set('to', options.to); + if (options.page) url.searchParams.set('page', String(options.page)); + url.searchParams.set('limit', String(options.limit ?? 50)); + + 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 moderation history (HTTP ${res.status})`); + } + return (await res.json()) as ModerationEventsResponse; +} + +export async function downloadModerationEventsCsv( + options: ModerationEventQuery = {} +): Promise { + const baseUrl = await getThemesServiceUrl(); + const headers = await getAuthHeaders(); + + if (!headers['X-Username'] || !headers['X-Password']) { + throw new Error('Please log in to export moderation history.'); + } + + const url = new URL(`${baseUrl}/v1/themes/moderation/events/export`); + if (options.themeId) url.searchParams.set('themeId', options.themeId); + if (options.action && options.action !== 'all') url.searchParams.set('action', options.action); + if (options.actor?.trim()) url.searchParams.set('actor', options.actor.trim()); + if (options.from) url.searchParams.set('from', options.from); + if (options.to) url.searchParams.set('to', options.to); + + 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 export moderation history (HTTP ${res.status})`); + } + + const blob = await res.blob(); + const disposition = res.headers.get('content-disposition') ?? ''; + const filename = /filename="([^"]+)"/.exec(disposition)?.[1] ?? 'theme-moderation-events.csv'; + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); +} + +export async function setCommunityThemeTags( + themeId: string, + tags: string[] +): Promise { + const baseUrl = await getThemesServiceUrl(); + const headers = await getAuthHeaders(); + + if (!headers['X-Username'] || !headers['X-Password']) { + throw new Error('Please log in to edit community theme tags.'); + } + + const res = await fetch(`${baseUrl}/v1/themes/${encodeURIComponent(themeId)}/tags`, { + method: 'PATCH', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ tags }), + }); + + if (!res.ok) { + const errorJson = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(errorJson.error || `Failed to update theme tags (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; +} + +export async function setCommunityThemeVisibility( + themeId: string, + hidden: boolean, + reason?: string +): Promise { + const baseUrl = await getThemesServiceUrl(); + const headers = await getAuthHeaders(); + + if (!headers['X-Username'] || !headers['X-Password']) { + throw new Error('Please log in to moderate community themes.'); + } + + const res = await fetch(`${baseUrl}/v1/themes/${encodeURIComponent(themeId)}/visibility`, { + method: 'PATCH', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ hidden, reason: hidden ? reason : undefined }), + }); + + if (!res.ok) { + const errorJson = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(errorJson.error || `Failed to update theme visibility (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; +} + /** * 3-step Cloudflare-compliant chunked upload protocol (~15 MB slices). */ @@ -366,7 +650,7 @@ export async function publishThemeToWorkshop(options: { tags?: string[]; previewImageBase64?: string; onProgress?: (status: string, percentage: number) => void; -}): Promise<{ success: boolean; theme: any }> { +}): Promise<{ success: boolean; theme: unknown }> { const { onProgress = () => {} } = options; const baseUrl = await getThemesServiceUrl(); const authHeaders = await getAuthHeaders(); diff --git a/src/lib/components/notifications/NotificationHub.svelte b/src/lib/components/notifications/NotificationHub.svelte new file mode 100644 index 0000000..0df76d1 --- /dev/null +++ b/src/lib/components/notifications/NotificationHub.svelte @@ -0,0 +1,303 @@ + + + + +{#if open} + + + +{/if} diff --git a/src/lib/components/theme/CommunityWorkshopView.svelte b/src/lib/components/theme/CommunityWorkshopView.svelte index 17b1740..c2a592a 100644 --- a/src/lib/components/theme/CommunityWorkshopView.svelte +++ b/src/lib/components/theme/CommunityWorkshopView.svelte @@ -2,12 +2,33 @@ import { onMount } from 'svelte'; import { fetchCommunityThemes, + fetchCommunityThemeDetails, toggleCommunityThemeLike, downloadAndInstallCommunityTheme, + forkCommunityTheme, + deleteCommunityTheme, + restoreCommunityTheme, + setCommunityThemeVisibility, + setCommunityThemeQuarantine, + setCommunityThemeTags, + reportCommunityTheme, + fetchThemeReports, + resolveThemeReport, + bulkResolveThemeReports, + downloadThemeReportsCsv, + downloadModerationEventsCsv, + fetchModerationEvents, type CommunityTheme, + type ModerationEvent, + type ModerationEventQuery, + type ThemeReport, + type ThemeReportCategory, + type ThemeReportQuery, + type ThemeReportStatus, } from '$lib/communityThemes'; import { active_custom_theme, custom_themes } from '$lib/global'; import { reduceAnimations } from '$lib/userSettings'; + import { currentUser } from '$lib/userAuthentication'; import { isThemeTrusted } from '$lib/themeTrust'; import type { Theme } from '$lib/themes'; import { Button } from '$lib/components/ui/button'; @@ -15,43 +36,160 @@ import Badge from '$lib/components/ui/badge/badge.svelte'; import ScrollContainer from '$lib/components/ui/scroll-container/ScrollContainer.svelte'; import { sileo } from 'sileo'; + import { sendSystemAnnouncement } from '$lib/notifications'; + import * as Tooltip from '$lib/components/ui/tooltip'; + import WorkshopTooltip from './WorkshopTooltip.svelte'; import { + Archive, + Ban, Check, + ChevronDown, Code2, + CopyPlus, Download, + ExternalLink, + Eye, + EyeOff, + Flag, + GitFork, Globe, Heart, + History, LoaderCircle, + Megaphone, + Pencil, RefreshCw, + RotateCcw, Search, ShieldCheck, Sparkles, Tag, + Trash2, + X, } from '@lucide/svelte'; import DefaultThemePreview from '$assets/default_preview.png'; let { onApplyTheme, onRequestTrust, + onEditTheme, }: { onApplyTheme: (theme: Theme) => Promise; onRequestTrust: (theme: Theme) => void; + onEditTheme: (theme: Theme) => Promise; } = $props(); + type WorkshopSort = 'relevance' | 'popular' | 'recent' | 'likes' | 'downloads'; + type WorkshopView = 'workshop' | 'trash'; + let searchQuery = $state(''); let selectedTag = $state('all'); - let selectedSort = $state<'popular' | 'recent' | 'likes' | 'downloads'>('popular'); + let selectedSort = $state('relevance'); + let selectedView = $state('workshop'); + let showHiddenOnly = $state(false); let themes = $state([]); let totalThemes = $state(0); + let page = $state(1); + let totalPages = $state(1); let loading = $state(false); + let loadingMore = $state(false); let error = $state(null); + let latestRequestId = 0; let downloadingId = $state(null); let downloadProgress = $state(0); let downloadStatus = $state(''); + let forkingId = $state(null); + let deletingId = $state(null); + let moderatingId = $state(null); + let restoringId = $state(null); + let canModerate = $state(false); + + let showModerationPanel = $state(false); + let moderationReports = $state([]); + let moderationEvents = $state([]); + let moderationLoading = $state(false); + let moderationError = $state(null); + let moderationTotal = $state(0); + let moderationPage = $state(1); + let moderationTotalPages = $state(1); + let moderationStatus = $state('open'); + let moderationQuery = $state(''); + let moderationAuthor = $state(''); + let moderationCategory = $state('all'); + let moderationFrom = $state(''); + let moderationTo = $state(''); + let moderationEventAction = $state('all'); + let moderationEventActor = $state(''); + let moderationEventsPage = $state(1); + let moderationEventsTotalPages = $state(1); + let selectedReportIds = $state([]); + let reportActionId = $state(null); + let bulkAction = $state<'hide' | 'resolved' | 'dismissed' | null>(null); + let exportingReports = $state(false); + let exportingEvents = $state(false); + let quarantineId = $state(null); + let tagEditingId = $state(null); + + let announcementOpen = $state(false); + let announcementTitle = $state(''); + let announcementMessage = $state(''); + let announcementAudience = $state<'all' | 'staff'>('all'); + let announcementSending = $state(false); + let announcementError = $state(null); + + let reportTarget = $state(null); + let reportCategory = $state('other'); + let reportDetails = $state(''); + let reportError = $state(null); + let reportSubmitting = $state(false); + + let historyOpen = $state(false); + let historyTarget = $state<{ id: string; name: string } | null>(null); + let historyEvents = $state([]); + let historyPage = $state(1); + let historyTotalPages = $state(1); + let historyLoading = $state(false); + let historyError = $state(null); + + let sourceDialogOpen = $state(false); + let sourceTheme = $state(null); + let sourceLoading = $state(false); + let sourceError = $state(null); const TAGS = ['all', 'legacy', 'anime', 'minimal', 'video', 'audio', 'game', 'dark', 'clean']; + const REPORT_CATEGORIES: Array<{ id: ThemeReportCategory; label: string }> = [ + { id: 'spam', label: 'Spam / advertising' }, + { id: 'harassment', label: 'Harassment / hate' }, + { id: 'malware', label: 'Malware / unsafe code' }, + { id: 'copyright', label: 'Copyright / stolen content' }, + { id: 'broken', label: 'Broken / unusable' }, + { id: 'other', label: 'Other' }, + ]; + const MODERATION_ACTIONS: Record = { + hide: 'Hidden from the workshop', + restore: 'Visibility restored', + delete: 'Moved to trash', + restore_deleted: 'Restored from trash', + report: 'Reported', + report_resolved: 'Report resolved', + report_dismissed: 'Report dismissed', + quarantine: 'Taken down', + quarantine_restore: 'Takedown lifted', + }; + const AUDIT_ACTIONS = [ + { id: 'all', label: 'All actions' }, + { id: 'hide', label: 'Hidden' }, + { id: 'restore', label: 'Visibility restored' }, + { id: 'quarantine', label: 'Taken down' }, + { id: 'quarantine_restore', label: 'Takedown lifted' }, + { id: 'report', label: 'Reported' }, + { id: 'report_resolved', label: 'Report resolved' }, + { id: 'report_dismissed', label: 'Report dismissed' }, + { id: 'delete', label: 'Trashed' }, + { id: 'restore_deleted', label: 'Restored from trash' }, + ]; const localThemeMap = $derived.by(() => { const map = new Map(); @@ -62,48 +200,117 @@ return map; }); - async function loadWorkshopThemes() { - loading = true; + const isOwnedByCurrentUser = (theme: CommunityTheme) => + theme.isOwner || ($currentUser !== undefined && theme.author.id === $currentUser.id); + + const selectableReports = $derived( + moderationReports.filter((report) => report.status === 'open' && !report.themeDeleted) + ); + const allSelectableReportsSelected = $derived( + selectableReports.length > 0 && + selectableReports.every((report) => selectedReportIds.includes(report.id)) + ); + + async function loadWorkshopThemes(options: { append?: boolean } = {}) { + const append = options.append === true; + const targetPage = append ? page + 1 : 1; + const requestId = ++latestRequestId; + + if (append) { + loadingMore = true; + } else { + loading = true; + } error = null; + try { const res = await fetchCommunityThemes({ q: searchQuery.trim().length > 0 ? searchQuery.trim() : undefined, - tag: selectedTag !== 'all' ? selectedTag : undefined, + tag: selectedView === 'workshop' && selectedTag !== 'all' ? selectedTag : undefined, sort: selectedSort, - limit: 50, + page: targetPage, + limit: 24, + hidden: selectedView === 'workshop' && canModerate && showHiddenOnly ? true : undefined, + trashed: selectedView === 'trash' ? true : undefined, }); - themes = res.items; + + // Ignore a response if a newer search/filter request has started. + if (requestId !== latestRequestId) return; + + if (append) { + const knownIds = new Set(themes.map((theme) => theme.id)); + themes = [...themes, ...res.items.filter((theme) => !knownIds.has(theme.id))]; + } else { + themes = res.items; + } totalThemes = res.total; + page = res.page; + totalPages = res.totalPages; + canModerate = res.canModerate; } catch (err) { + if (requestId !== latestRequestId) return; console.warn('[Workshop] Failed to load community themes:', err); error = err instanceof Error ? err.message : String(err); } finally { - loading = false; + if (requestId === latestRequestId) { + loading = false; + loadingMore = false; + } } } - let searchDebounceTimeout: any = null; + let searchDebounceTimeout: ReturnType | undefined; function handleSearchInput() { clearTimeout(searchDebounceTimeout); searchDebounceTimeout = setTimeout(() => { - loadWorkshopThemes(); + void loadWorkshopThemes(); }, 300); } function handleTagClick(tag: string) { - if (selectedTag === tag) return; + if (selectedTag === tag && selectedView === 'workshop') return; + selectedView = 'workshop'; selectedTag = tag; - loadWorkshopThemes(); + void loadWorkshopThemes(); } - function handleSortChange(sort: 'popular' | 'recent' | 'likes' | 'downloads') { + function handleViewChange(view: WorkshopView) { + if (selectedView === view) return; + selectedView = view; + selectedTag = 'all'; + showHiddenOnly = false; + void loadWorkshopThemes(); + } + + function handleHiddenFilter() { + if (!canModerate || selectedView !== 'workshop') return; + showHiddenOnly = !showHiddenOnly; + void loadWorkshopThemes(); + } + + function handleLoadMore() { + if (loading || loadingMore || page >= totalPages) return; + void loadWorkshopThemes({ append: true }); + } + + function parseWorkshopSort(value: string): WorkshopSort { + return value === 'relevance' || + value === 'recent' || + value === 'likes' || + value === 'downloads' + ? value + : 'popular'; + } + + function handleSortChange(sort: WorkshopSort) { if (selectedSort === sort) return; selectedSort = sort; - loadWorkshopThemes(); + void loadWorkshopThemes(); } async function handleLike(theme: CommunityTheme, event: MouseEvent) { event.stopPropagation(); + if (theme.deletedAt) return; const prevLiked = theme.isLiked; const prevCount = theme.likeCount; @@ -127,7 +334,7 @@ } async function handleDownloadAndApply(theme: CommunityTheme) { - if (downloadingId) return; + if (downloadingId || theme.deletedAt) return; downloadingId = theme.id; downloadProgress = 10; @@ -170,6 +377,7 @@ } async function handleApplyExisting(theme: CommunityTheme) { + if (theme.deletedAt) return; const installed = localThemeMap.get(theme.id.toLowerCase()) || localThemeMap.get(theme.name.toLowerCase()); @@ -185,6 +393,692 @@ } } + async function handleEditTheme(theme: CommunityTheme) { + if (theme.deletedAt || downloadingId || forkingId || deletingId || moderatingId) return; + + let installed = + localThemeMap.get(theme.id.toLowerCase()) || + localThemeMap.get(theme.name.toLowerCase()); + + if (!installed) { + downloadingId = theme.id; + downloadProgress = 10; + downloadStatus = 'Downloading theme package...'; + try { + await downloadAndInstallCommunityTheme(theme, (msg, progress) => { + downloadStatus = msg; + downloadProgress = progress; + }); + installed = $custom_themes.find( + (candidate) => candidate.folder_name.toLowerCase() === theme.id.toLowerCase() + ); + } catch (err) { + sileo.error({ + title: 'Could not open theme', + description: err instanceof Error ? err.message : String(err), + }); + return; + } finally { + downloadingId = null; + downloadProgress = 0; + downloadStatus = ''; + } + } + + if (installed) { + await onEditTheme(installed); + } + } + + async function handleRefreshOwnedTheme(theme: CommunityTheme) { + if (theme.deletedAt || downloadingId || forkingId || deletingId || moderatingId) return; + + const installed = + localThemeMap.get(theme.id.toLowerCase()) || + localThemeMap.get(theme.name.toLowerCase()); + if (!installed) { + await handleEditTheme(theme); + return; + } + if (installed.version === theme.version) { + await onEditTheme(installed); + return; + } + + const confirmed = window.confirm( + `Refresh "${theme.name}" from the workshop?\n\nLocal version: ${installed.version}\nWorkshop version: ${theme.version}\n\nYour local edits will be replaced by the workshop package.` + ); + if (!confirmed) return; + + downloadingId = theme.id; + downloadProgress = 10; + downloadStatus = 'Refreshing local theme...'; + try { + const res = await downloadAndInstallCommunityTheme(theme, (msg, progress) => { + downloadStatus = msg; + downloadProgress = progress; + }); + const refreshed = $custom_themes.find( + (candidate) => candidate.folder_name.toLowerCase() === res.folderName.toLowerCase() + ); + if (!refreshed) { + throw new Error('The refreshed theme could not be loaded.'); + } + sileo.success({ + title: 'Local copy refreshed', + description: `"${theme.name}" is now at v${theme.version}.`, + }); + await onEditTheme(refreshed); + } catch (err) { + sileo.error({ + title: 'Refresh failed', + description: err instanceof Error ? err.message : String(err), + }); + } finally { + downloadingId = null; + downloadProgress = 0; + downloadStatus = ''; + } + } + + async function handleToggleVisibility(theme: CommunityTheme) { + if (!canModerate || moderatingId || downloadingId || forkingId || deletingId) return; + + const hiding = !theme.isHidden; + let reason: string | undefined; + if (hiding) { + const answer = window.prompt( + `Hide "${theme.name}" from the Community Workshop?\n\nOptional reason (visible to moderators):`, + theme.hiddenReason ?? '' + ); + if (answer === null) return; + reason = answer.trim().slice(0, 255) || undefined; + } else if (!window.confirm(`Restore "${theme.name}" to the Community Workshop?`)) { + return; + } + + moderatingId = theme.id; + try { + const updated = await setCommunityThemeVisibility(theme.id, hiding, reason); + if (showHiddenOnly && !updated.isHidden) { + themes = themes.filter((item) => item.id !== updated.id); + totalThemes = Math.max(0, totalThemes - 1); + } else { + themes = themes.map((item) => (item.id === updated.id ? updated : item)); + } + sileo.success({ + title: hiding ? 'Theme hidden' : 'Theme restored', + description: `"${theme.name}" is now ${hiding ? 'hidden from' : 'visible in'} the workshop.`, + }); + if (showModerationPanel) void loadModerationPanel(); + } catch (err) { + sileo.error({ + title: 'Could not update visibility', + description: err instanceof Error ? err.message : String(err), + }); + } finally { + moderatingId = null; + } + } + + async function handleEditTags(theme: CommunityTheme) { + if (tagEditingId || theme.deletedAt) return; + const raw = window.prompt( + `Tags for "${theme.name}" (comma-separated, up to 10):`, + theme.tags.join(', ') + ); + if (raw === null) return; + + const tags = raw + .split(',') + .map((tag) => tag.trim()) + .filter(Boolean); + + tagEditingId = theme.id; + try { + const updated = await setCommunityThemeTags(theme.id, tags); + themes = themes.map((item) => (item.id === updated.id ? updated : item)); + sileo.success({ + title: 'Theme tags updated', + description: updated.tags.length > 0 + ? `Tags: ${updated.tags.map((tag) => `#${tag}`).join(', ')}` + : 'All tags were removed.', + }); + await loadWorkshopThemes(); + } catch (err) { + sileo.error({ + title: 'Could not update tags', + description: err instanceof Error ? err.message : String(err), + }); + } finally { + tagEditingId = null; + } + } + + async function handleForkTheme(theme: CommunityTheme) { + if (theme.deletedAt || theme.isHidden || downloadingId || forkingId || deletingId || moderatingId) return; + if (!$currentUser) { + sileo.error({ + title: 'Login required', + description: 'Log in to EZPPFarm to fork community themes.', + }); + return; + } + + forkingId = theme.id; + let fork: CommunityTheme; + try { + fork = await forkCommunityTheme(theme.id); + } catch (err) { + forkingId = null; + sileo.error({ + title: 'Could not fork theme', + description: err instanceof Error ? err.message : String(err), + }); + return; + } + forkingId = null; + + let installed: Theme | undefined; + try { + await loadWorkshopThemes(); + downloadingId = fork.id; + downloadProgress = 10; + downloadStatus = 'Downloading your fork...'; + const res = await downloadAndInstallCommunityTheme(fork, (msg, progress) => { + downloadStatus = msg; + downloadProgress = progress; + }); + installed = $custom_themes.find( + (candidate) => candidate.folder_name.toLowerCase() === res.folderName.toLowerCase() + ); + } catch (err) { + sileo.error({ + title: 'Fork created, but download failed', + description: `${err instanceof Error ? err.message : String(err)} You can download it from the workshop.`, + }); + } finally { + downloadingId = null; + downloadProgress = 0; + downloadStatus = ''; + } + + if (installed) { + sileo.success({ + title: 'Theme forked', + description: `"${fork.name}" is now yours. Edit and publish it when you're ready.`, + }); + await onEditTheme(installed); + } + } + + async function handleDeleteTheme(theme: CommunityTheme) { + if (theme.deletedAt || deletingId || downloadingId || forkingId || moderatingId) return; + const confirmed = window.confirm( + `Move "${theme.name}" to the trash?\n\nYou can restore it from the Trash view later. Installed copies stay on your computer.` + ); + if (!confirmed) return; + + deletingId = theme.id; + try { + await deleteCommunityTheme(theme.id); + themes = themes.filter((item) => item.id !== theme.id); + totalThemes = Math.max(0, totalThemes - 1); + sileo.success({ + title: 'Moved to trash', + description: `"${theme.name}" can be restored from the Trash view.`, + }); + } catch (err) { + sileo.error({ + title: 'Could not delete theme', + description: err instanceof Error ? err.message : String(err), + }); + } finally { + deletingId = null; + } + } + + async function handleRestoreTheme(theme: CommunityTheme) { + if (restoringId || downloadingId || forkingId || deletingId || moderatingId) return; + if (!window.confirm(`Restore "${theme.name}" to the workshop?`)) return; + + restoringId = theme.id; + try { + await restoreCommunityTheme(theme.id); + themes = themes.filter((item) => item.id !== theme.id); + totalThemes = Math.max(0, totalThemes - 1); + sileo.success({ + title: 'Theme restored', + description: `"${theme.name}" is back in the workshop.`, + }); + void loadWorkshopThemes(); + } catch (err) { + sileo.error({ + title: 'Could not restore theme', + description: err instanceof Error ? err.message : String(err), + }); + } finally { + restoringId = null; + } + } + + function openReportDialog(theme: CommunityTheme) { + if (theme.deletedAt || theme.isHidden) return; + if (!$currentUser) { + sileo.error({ + title: 'Login required', + description: 'Log in to EZPPFarm to report a community theme.', + }); + return; + } + + reportTarget = theme; + reportCategory = 'other'; + reportDetails = ''; + reportError = null; + } + + async function submitReport() { + const target = reportTarget; + if (!target || reportSubmitting) return; + const reason = reportDetails.trim(); + if (reason.length < 10) { + reportError = 'Please describe the problem in at least 10 characters.'; + return; + } + + reportSubmitting = true; + reportError = null; + try { + await reportCommunityTheme(target.id, reason.slice(0, 500), reportCategory); + sileo.success({ + title: 'Report submitted', + description: 'Thank you. A moderator will review the theme.', + }); + reportTarget = null; + reportDetails = ''; + reportCategory = 'other'; + } catch (err) { + reportError = err instanceof Error ? err.message : String(err); + } finally { + reportSubmitting = false; + } + } + + function currentReportFilters(): ThemeReportQuery { + return { + status: moderationStatus, + q: moderationQuery.trim() || undefined, + category: moderationCategory, + author: moderationAuthor.trim() || undefined, + from: moderationFrom || undefined, + to: moderationTo || undefined, + }; + } + + function currentEventFilters(): ModerationEventQuery { + return { + action: moderationEventAction, + actor: moderationEventActor.trim() || undefined, + from: moderationFrom || undefined, + to: moderationTo || undefined, + }; + } + + async function loadModerationPanel(page = moderationPage) { + if (!canModerate) return; + moderationLoading = true; + moderationError = null; + try { + const [reportsResult, eventsResult] = await Promise.all([ + fetchThemeReports({ ...currentReportFilters(), page, limit: 50 }), + fetchModerationEvents({ ...currentEventFilters(), page: 1, limit: 50 }), + ]); + moderationReports = reportsResult.reports; + moderationTotal = reportsResult.total; + moderationPage = reportsResult.page; + moderationTotalPages = reportsResult.totalPages; + moderationEvents = eventsResult.events; + moderationEventsPage = eventsResult.page; + moderationEventsTotalPages = eventsResult.totalPages; + selectedReportIds = []; + } catch (err) { + moderationError = err instanceof Error ? err.message : String(err); + } finally { + moderationLoading = false; + } + } + + function openModerationPanel() { + showModerationPanel = true; + void loadModerationPanel(1); + } + + function handleModerationFilterChange() { + moderationPage = 1; + moderationEventsPage = 1; + selectedReportIds = []; + void loadModerationPanel(1); + } + + function handleAuditFilterChange() { + moderationEventsPage = 1; + selectedReportIds = []; + void loadModerationPanel(moderationPage); + } + + function goToModerationPage(page: number) { + if (page < 1 || page > moderationTotalPages || page === moderationPage) return; + selectedReportIds = []; + void loadModerationPanel(page); + } + + async function loadOlderEvents() { + if (moderationEventsPage >= moderationEventsTotalPages) return; + try { + const result = await fetchModerationEvents({ + ...currentEventFilters(), + page: moderationEventsPage + 1, + limit: 50, + }); + const known = new Set(moderationEvents.map((event) => event.id)); + moderationEvents = [ + ...moderationEvents, + ...result.events.filter((event) => !known.has(event.id)), + ]; + moderationEventsPage = result.page; + moderationEventsTotalPages = result.totalPages; + } catch (err) { + moderationError = err instanceof Error ? err.message : String(err); + } + } + + async function handleExportEvents() { + if (exportingEvents) return; + exportingEvents = true; + try { + await downloadModerationEventsCsv(currentEventFilters()); + sileo.success({ title: 'Moderation history exported' }); + } catch (err) { + sileo.error({ + title: 'Could not export moderation history', + description: err instanceof Error ? err.message : String(err), + }); + } finally { + exportingEvents = false; + } + } + + async function handleToggleQuarantine(theme: CommunityTheme) { + if (!canModerate || quarantineId) return; + const takingDown = !theme.isQuarantined; + const reason = takingDown + ? window.prompt( + `Take down "${theme.name}"?\n\nReason shown to the owner and stored in the audit log:`, + theme.quarantineReason || '' + ) + : window.prompt( + `Lift the takedown on "${theme.name}"?\n\nOptional note for the audit log:`, + '' + ); + if (reason === null) return; + if (takingDown && reason.trim().length < 3) { + sileo.error({ title: 'A takedown reason is required' }); + return; + } + + quarantineId = theme.id; + try { + await setCommunityThemeQuarantine(theme.id, takingDown, reason.trim() || undefined); + sileo.success({ + title: takingDown ? 'Theme taken down' : 'Takedown lifted', + description: `"${theme.name}" was updated.`, + }); + await loadWorkshopThemes(); + if (showModerationPanel) await loadModerationPanel(moderationPage); + } catch (err) { + sileo.error({ + title: 'Could not update takedown', + description: err instanceof Error ? err.message : String(err), + }); + } finally { + quarantineId = null; + } + } + + function openAnnouncementComposer() { + announcementOpen = true; + announcementTitle = ''; + announcementMessage = ''; + announcementAudience = 'all'; + announcementError = null; + } + + async function submitAnnouncement() { + if (announcementSending) return; + const title = announcementTitle.trim(); + const message = announcementMessage.trim(); + if (title.length < 3) { + announcementError = 'Title must be at least 3 characters.'; + return; + } + if (message.length < 10) { + announcementError = 'Message must be at least 10 characters.'; + return; + } + + announcementSending = true; + announcementError = null; + try { + const result = await sendSystemAnnouncement({ + title: title.slice(0, 120), + message: message.slice(0, 1000), + audience: announcementAudience, + }); + announcementOpen = false; + sileo.success({ + title: 'Announcement sent', + description: `${result.recipients} recipient(s) notified.`, + }); + } catch (err) { + announcementError = err instanceof Error ? err.message : String(err); + } finally { + announcementSending = false; + } + } + + function toggleReportSelection(reportId: number) { + selectedReportIds = selectedReportIds.includes(reportId) + ? selectedReportIds.filter((id) => id !== reportId) + : [...selectedReportIds, reportId]; + } + + function toggleAllReports() { + if (allSelectableReportsSelected) { + selectedReportIds = []; + } else { + selectedReportIds = selectableReports.map((report) => report.id); + } + } + + async function handleBulkReports(action: 'hide' | 'resolved' | 'dismissed') { + if (selectedReportIds.length === 0 || bulkAction) return; + const note = window.prompt( + action === 'hide' + ? 'Optional note for the theme owners and reporters:' + : 'Optional note for the reporters:', + '' + ); + if (note === null) return; + + bulkAction = action; + try { + const result = await bulkResolveThemeReports( + selectedReportIds, + action, + note.trim() || undefined + ); + sileo.success({ + title: 'Reports updated', + description: `${result.updated} report(s) processed across ${result.themes} theme(s).`, + }); + selectedReportIds = []; + await loadModerationPanel(1); + await loadWorkshopThemes(); + } catch (err) { + sileo.error({ + title: 'Could not update reports', + description: err instanceof Error ? err.message : String(err), + }); + } finally { + bulkAction = null; + } + } + + async function handleExportReports() { + if (exportingReports) return; + exportingReports = true; + try { + const truncated = await downloadThemeReportsCsv(currentReportFilters()); + sileo.success({ + title: 'Report export downloaded', + description: truncated ? 'The export is capped at 5,000 rows. Narrow the date range for the rest.' : undefined, + }); + } catch (err) { + sileo.error({ + title: 'Could not export reports', + description: err instanceof Error ? err.message : String(err), + }); + } finally { + exportingReports = false; + } + } + + async function handleReportHide(report: ThemeReport) { + if (reportActionId !== null) return; + const note = window.prompt( + `Hide "${report.themeName}"?\n\nOptional note for the reporter and audit log:`, + report.reason.slice(0, 255) + ); + if (note === null) return; + + reportActionId = report.id; + try { + await setCommunityThemeVisibility(report.themeId, true, note.trim() || report.reason); + sileo.success({ + title: 'Theme hidden', + description: `"${report.themeName}" was hidden and the report was resolved.`, + }); + await loadModerationPanel(); + await loadWorkshopThemes(); + } catch (err) { + sileo.error({ + title: 'Could not hide theme', + description: err instanceof Error ? err.message : String(err), + }); + } finally { + reportActionId = null; + } + } + + async function handleDismissReport(report: ThemeReport) { + if (reportActionId !== null) return; + const note = window.prompt( + `Dismiss the report on "${report.themeName}"?\n\nOptional note for the reporter:`, + '' + ); + if (note === null) return; + + reportActionId = report.id; + try { + await resolveThemeReport(report.id, 'dismissed', note.trim() || undefined); + sileo.success({ title: 'Report dismissed' }); + await loadModerationPanel(1); + } catch (err) { + sileo.error({ + title: 'Could not dismiss report', + description: err instanceof Error ? err.message : String(err), + }); + } finally { + reportActionId = null; + } + } + + async function openThemeHistory(themeId: string, themeName: string) { + historyOpen = true; + historyTarget = { id: themeId, name: themeName }; + historyEvents = []; + historyPage = 1; + historyTotalPages = 1; + historyError = null; + historyLoading = true; + try { + const result = await fetchModerationEvents({ themeId, page: 1, limit: 50 }); + historyEvents = result.events; + historyPage = result.page; + historyTotalPages = result.totalPages; + } catch (err) { + historyError = err instanceof Error ? err.message : String(err); + } finally { + historyLoading = false; + } + } + + async function loadOlderHistory() { + if (!historyTarget || historyPage >= historyTotalPages) return; + historyLoading = true; + try { + const result = await fetchModerationEvents({ + themeId: historyTarget.id, + page: historyPage + 1, + limit: 50, + }); + const known = new Set(historyEvents.map((event) => event.id)); + historyEvents = [ + ...historyEvents, + ...result.events.filter((event) => !known.has(event.id)), + ]; + historyPage = result.page; + historyTotalPages = result.totalPages; + } catch (err) { + historyError = err instanceof Error ? err.message : String(err); + } finally { + historyLoading = false; + } + } + + async function handleOpenForkSource(theme: CommunityTheme) { + if (!theme.forkedFrom) return; + sourceDialogOpen = true; + sourceLoading = true; + sourceError = null; + sourceTheme = null; + try { + sourceTheme = await fetchCommunityThemeDetails(theme.forkedFrom.id); + } catch { + sourceError = 'The original theme is no longer available in the workshop.'; + } finally { + sourceLoading = false; + } + } + + function moderationActionLabel(action: string): string { + return MODERATION_ACTIONS[action] ?? action; + } + + function reportCategoryLabel(category: ThemeReportCategory): string { + 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 { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); + } + function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; const k = 1024; @@ -198,32 +1092,58 @@ }); +
- +
- {#each TAGS as tag} - - {/each} + {#if selectedView === 'trash'} + + + Your trash + + {:else} + {#each TAGS as tag} + + {/each} + {#if canModerate} + + {#snippet trigger(props)} + + {/snippet} + + {/if} + {/if}
- +
+ + {#snippet trigger(props)} + + {/snippet} +
- + {#if canModerate} + + {#snippet trigger(props)} + + {/snippet} + + {/if} + + {#if $currentUser} + + {#snippet trigger(props)} + + {/snippet} + + {/if} + + + {#snippet trigger(props)} + + {/snippet} +
@@ -273,7 +1235,7 @@ {error}. Ensure the themes microservice container is running.

- @@ -290,7 +1252,9 @@ {@const isInstalled = Boolean(localTheme)} {@const isActive = Boolean(localTheme && $active_custom_theme?.folder_name === localTheme.folder_name)} {@const isDownloading = downloadingId === theme.id} - {@const hasUpdate = Boolean(localTheme && localTheme.version !== theme.version)} + {@const isDeleted = Boolean(theme.deletedAt)} + {@const hasUpdate = Boolean(!isDeleted && localTheme && localTheme.version !== theme.version)} + {@const owned = isOwnedByCurrentUser(theme)}
{#if theme.apiVersion === '1.0' || theme.tags?.includes('legacy')} - - - Legacy v1 - + + {#snippet trigger(props)} + + + Legacy v1 + + {/snippet} + {:else if !theme.hasExecutableCode} - - - Declarative - + + {#snippet trigger(props)} + + + Declarative + + {/snippet} + {:else} - + {#snippet trigger(props)} + + + Svelte Code + + {/snippet} + + {/if} + {#if theme.isHidden} + - - Svelte Code - + {#snippet trigger(props)} + + + Hidden + + {/snippet} + + {/if} + {#if theme.isQuarantined} + + {#snippet trigger(props)} + + + Taken down + + {/snippet} + + {/if} + {#if isDeleted} + + {#snippet trigger(props)} + + + Trash + + {/snippet} + {/if}
@@ -361,13 +1390,18 @@ {theme.author.name} {#if theme.author.donor} - - - Supporter - + + {#snippet trigger(props)} + + + Supporter + + {/snippet} + {/if} @@ -385,17 +1419,27 @@ - + {#if isDeleted} + + Deleted + + {:else} + + {#snippet trigger(props)} + + {/snippet} + + {/if} {#if theme.description} @@ -413,6 +1457,115 @@ {/each} {/if} + + {#if owned && !isDeleted} + + {#snippet trigger(props)} + + {/snippet} + + {/if} + + {#if theme.searchMatch} + {@const searchMatch = theme.searchMatch} + + {#snippet trigger(props)} +
+ + + Matched {searchMatchLabel(searchMatch.field).toLowerCase()}: + {searchMatch.value} + +
+ {/snippet} +
+ {/if} + + {#if theme.isHidden || theme.isQuarantined} +
+ {#if theme.isHidden} +

+ + Hidden by {theme.hiddenByName ?? 'staff'} + {#if theme.hiddenAt} + · {formatModerationDate(theme.hiddenAt)} + {/if} +

+ {#if theme.hiddenReason} +

{theme.hiddenReason}

+ {/if} + {/if} + {#if theme.isQuarantined} +

+ + Taken down by {theme.quarantinedByName ?? 'staff'} + {#if theme.quarantinedAt} + · {formatModerationDate(theme.quarantinedAt)} + {/if} +

+ {#if theme.quarantineReason} +

{theme.quarantineReason}

+ {/if} + {/if} +
+ {/if} + + {#if theme.forkedFrom} + {@const forkedFrom = theme.forkedFrom} + + {#snippet trigger(props)} + + {/snippet} + + {/if} + + {#if canModerate} + + {#snippet trigger(props)} + + {/snippet} + + {/if} @@ -422,7 +1575,7 @@ {theme.downloadCount} -
+
{#if isDownloading}
{downloadProgress}% @@ -433,36 +1586,227 @@ >
- {:else if isActive} - - In use - - {:else if isInstalled && hasUpdate} - - {:else if isInstalled} - + {:else if deletingId === theme.id} + + Deleting + + {:else if forkingId === theme.id} + + Forking + + {:else if moderatingId === theme.id} + + Updating + + {:else if owned} + {#if isDeleted} + + {:else} + {#if canModerate && theme.apiVersion !== '1.0' && !theme.tags?.includes('legacy')} + + {#snippet trigger(props)} + + {/snippet} + + {/if} + {#if canModerate} + + {#snippet trigger(props)} + + {/snippet} + + {/if} + {#if isInstalled && hasUpdate} + + {#snippet trigger(props)} + + {/snippet} + + {/if} + + + {#snippet trigger(props)} + + {/snippet} + + {/if} {:else} - + {#if isDeleted} + + Deleted + + {:else} + {#if canModerate && theme.apiVersion !== '1.0' && !theme.tags?.includes('legacy')} + + {#snippet trigger(props)} + + {/snippet} + + {/if} + {#if canModerate} + + {#snippet trigger(props)} + + {/snippet} + + {/if} + {#if $currentUser && !theme.isHidden && !theme.isQuarantined && theme.apiVersion !== '1.0' && !theme.tags?.includes('legacy')} + + {#snippet trigger(props)} + + {/snippet} + + {/if} + {#if theme.apiVersion !== '1.0' && !theme.tags?.includes('legacy')} + + {#snippet trigger(props)} + + {/snippet} + + {/if} + {#if isActive} + + In use + + {:else if isInstalled && hasUpdate} + + {:else if isInstalled} + + {:else} + + {/if} + {/if} {/if} @@ -470,7 +1814,856 @@ {/each} + {#if page < totalPages} +
+ +
+ {/if} {/if} + +{#if showModerationPanel} +
+ + +
+{/if} + +{#if announcementOpen} +
+ + +
+{/if} + +{#if sourceDialogOpen} +
+ + +
+{/if} + +{#if reportTarget} +
+ + +
+{/if} + +{#if historyOpen && historyTarget} +
+ + +
+{/if} +
diff --git a/src/lib/components/theme/WorkshopTooltip.svelte b/src/lib/components/theme/WorkshopTooltip.svelte new file mode 100644 index 0000000..1de96b1 --- /dev/null +++ b/src/lib/components/theme/WorkshopTooltip.svelte @@ -0,0 +1,29 @@ + + + + + {#snippet child({ props })} + {@render trigger(props)} + {/snippet} + + + {content} + + diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts new file mode 100644 index 0000000..be63343 --- /dev/null +++ b/src/lib/notifications.ts @@ -0,0 +1,100 @@ +import { getAuthHeaders, getThemesServiceUrl } from './communityThemes'; + +export type NotificationType = + | 'theme_hidden' + | 'theme_restored' + | 'theme_taken_down' + | 'report_resolved' + | 'report_dismissed' + | 'system'; + +export interface UserNotification { + id: number; + type: string; + title: string; + message: string; + themeId: string | null; + themeName: string | null; + actorId: number | null; + readAt: string | null; + createdAt: string; +} + +export interface NotificationsResponse { + notifications: UserNotification[]; + unreadCount: number; + hasMore: boolean; +} + +async function requireAuthHeaders(): Promise> { + const headers = await getAuthHeaders(); + if (!headers['X-Username'] || !headers['X-Password']) { + throw new Error('Please log in to view notifications.'); + } + return headers; +} + +async function parseError(res: Response, fallback: string): Promise { + const errorJson = (await res.json().catch(() => ({}))) as { error?: string }; + return new Error(errorJson.error || `${fallback} (HTTP ${res.status})`); +} + +export async function fetchNotifications(options?: { + limit?: number; + beforeId?: number; +}): Promise { + const baseUrl = await getThemesServiceUrl(); + const headers = await requireAuthHeaders(); + const url = new URL(`${baseUrl}/v1/notifications`); + if (options?.limit) url.searchParams.set('limit', String(options.limit)); + if (options?.beforeId) url.searchParams.set('beforeId', String(options.beforeId)); + + const res = await fetch(url.toString(), { headers }); + if (!res.ok) throw await parseError(res, 'Failed to load notifications'); + return (await res.json()) as NotificationsResponse; +} + +export async function fetchUnreadNotificationCount(): Promise { + const baseUrl = await getThemesServiceUrl(); + const headers = await requireAuthHeaders(); + const res = await fetch(`${baseUrl}/v1/notifications/unread-count`, { headers }); + if (!res.ok) throw await parseError(res, 'Failed to load notification count'); + const data = (await res.json()) as { count?: number }; + return Number(data.count ?? 0); +} + +export async function markNotificationRead(notificationId: number): Promise { + const baseUrl = await getThemesServiceUrl(); + const headers = await requireAuthHeaders(); + const res = await fetch(`${baseUrl}/v1/notifications/${notificationId}/read`, { + method: 'PATCH', + headers, + }); + if (!res.ok) throw await parseError(res, 'Failed to update notification'); +} + +export async function markAllNotificationsRead(): Promise { + const baseUrl = await getThemesServiceUrl(); + const headers = await requireAuthHeaders(); + const res = await fetch(`${baseUrl}/v1/notifications/read-all`, { + method: 'POST', + headers, + }); + if (!res.ok) throw await parseError(res, 'Failed to update notifications'); +} + +export async function sendSystemAnnouncement(options: { + title: string; + message: string; + audience?: 'all' | 'staff'; +}): Promise<{ recipients: number }> { + const baseUrl = await getThemesServiceUrl(); + const headers = await requireAuthHeaders(); + const res = await fetch(`${baseUrl}/v1/notifications/announce`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(options), + }); + if (!res.ok) throw await parseError(res, 'Failed to send announcement'); + return (await res.json()) as { recipients: number }; +} diff --git a/src/screens/Launch.svelte b/src/screens/Launch.svelte index e90ceac..f748943 100644 --- a/src/screens/Launch.svelte +++ b/src/screens/Launch.svelte @@ -4,6 +4,7 @@ import { ezppfarm } from '$lib/api/ezpp'; import DiagnosticsPanel from '$lib/components/diagnostics/DiagnosticsPanel.svelte'; import LogViewer from '$lib/components/logs/LogViewer.svelte'; + import NotificationHub from '$lib/components/notifications/NotificationHub.svelte'; import DiscordPresenceEditor from '$lib/components/presence/DiscordPresenceEditor.svelte'; import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte'; import ThemeV2Elements from '$lib/components/theme/ThemeV2Elements.svelte'; @@ -2020,7 +2021,8 @@ > -
+
+ @@ -3730,6 +3732,7 @@ requestThemeTrust(theme, false)} + onEditTheme={openThemeEditor} /> {:else}