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.
This commit is contained in:
HorizonCode committed 2026-11-03 10:03:53 +01:00
1 parent 1bf8034126
commit 9b4158410d
8 files changed
+3147 -232

No files matched your search

+3 -1
View File
@@ -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 |
| ----------------------- | ------------------------------------------------- | ---------------------------------------------- |
+1
View File
@@ -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)$' },
+488 -204
View File
@@ -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,14 +170,13 @@ 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<CommunityThemesResponse> {
let remoteData: CommunityThemesResponse | null = null;
let remoteError: Error | null = null;
try {
const baseUrl = await getThemesServiceUrl();
const headers = await getAuthHeaders();
@@ -231,10 +187,17 @@ export async function fetchCommunityThemes(options?: {
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 res = await fetch(url.toString(), { headers });
if (res.ok) {
remoteData = (await res.json()) as CommunityThemesResponse;
if (!res.ok) {
const errorJson = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(errorJson.error || `Failed to load themes (HTTP ${res.status})`);
}
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}`;
@@ -243,59 +206,28 @@ export async function fetchCommunityThemes(options?: {
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);
}
// 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;
}
return true;
});
if (!remoteData) {
if (matchingLegacy.length > 0 || !remoteError) {
return {
items: matchingLegacy,
total: matchingLegacy.length,
page: 1,
totalPages: 1,
};
}
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<CommunityTheme> {
const cleanId = themeId.trim().toLowerCase();
try {
const baseUrl = await getThemesServiceUrl();
const headers = await getAuthHeaders();
const res = await fetch(`${baseUrl}/v1/themes/${encodeURIComponent(cleanId)}`, { headers });
if (res.ok) {
if (!res.ok) {
const errorJson = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(errorJson.error || 'Theme not found.');
}
const item = (await res.json()) as CommunityTheme;
if (item.previewUrl && !item.previewUrl.startsWith('http')) {
item.previewUrl = `${baseUrl}${item.previewUrl}`;
@@ -305,13 +237,6 @@ export async function fetchCommunityThemeDetails(themeId: string): Promise<Commu
}
return item;
}
} catch {}
const legacy = LEGACY_COMMUNITY_THEMES.find((t) => t.id === cleanId);
if (legacy) return legacy;
throw new Error('Theme not found.');
}
export async function toggleCommunityThemeLike(themeId: string): Promise<{ liked: boolean; likeCount: number }> {
const baseUrl = await getThemesServiceUrl();
@@ -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<CommunityTheme> {
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<void> {
const baseUrl = await getThemesServiceUrl();
const headers = await getAuthHeaders();
@@ -353,6 +306,337 @@ export async function deleteCommunityTheme(themeId: string): Promise<void> {
}
}
export async function reportCommunityTheme(
themeId: string,
reason: string,
category: ThemeReportCategory = 'other'
): Promise<void> {
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<CommunityTheme> {
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<ThemeReportsResponse> {
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<void> {
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<boolean> {
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<CommunityTheme> {
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<ModerationEventsResponse> {
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<void> {
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<CommunityTheme> {
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<CommunityTheme> {
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();
@@ -0,0 +1,303 @@
<script lang="ts">
import { Bell, CheckCheck, LoaderCircle, Megaphone, RefreshCw, ShieldAlert, X } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { currentUser } from '$lib/userAuthentication';
import {
fetchNotifications,
fetchUnreadNotificationCount,
markAllNotificationsRead,
markNotificationRead,
type UserNotification,
} from '$lib/notifications';
let open = $state(false);
let loading = $state(false);
let loadingMore = $state(false);
let markingAll = $state(false);
let notifications = $state<UserNotification[]>([]);
let unreadCount = $state(0);
let hasMore = $state(false);
let error = $state<string | null>(null);
const unreadLabel = $derived(unreadCount > 9 ? '9+' : String(unreadCount));
$effect(() => {
const userId = $currentUser?.id;
if (!userId) {
open = false;
notifications = [];
unreadCount = 0;
hasMore = false;
error = null;
return;
}
void refreshCount();
const timer = setInterval(() => {
if (document.visibilityState === 'visible' && !open) void refreshCount();
}, 60_000);
return () => clearInterval(timer);
});
async function refreshCount() {
if (!$currentUser) return;
try {
unreadCount = await fetchUnreadNotificationCount();
} catch {
// The badge is best-effort; the panel surfaces actionable errors.
}
}
async function loadNotifications() {
if (!$currentUser) return;
loading = true;
error = null;
try {
const data = await fetchNotifications({ limit: 30 });
notifications = data.notifications;
unreadCount = data.unreadCount;
hasMore = data.hasMore;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
function openHub() {
if (!$currentUser) return;
open = true;
void loadNotifications();
}
async function loadOlder() {
if (loadingMore || notifications.length === 0) return;
loadingMore = true;
try {
const data = await fetchNotifications({
limit: 30,
beforeId: notifications[notifications.length - 1].id,
});
const known = new Set(notifications.map((item) => item.id));
notifications = [...notifications, ...data.notifications.filter((item) => !known.has(item.id))];
unreadCount = data.unreadCount;
hasMore = data.hasMore;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loadingMore = false;
}
}
async function markRead(notification: UserNotification) {
if (notification.readAt) return;
try {
await markNotificationRead(notification.id);
notifications = notifications.map((item) =>
item.id === notification.id ? { ...item, readAt: new Date().toISOString() } : item
);
unreadCount = Math.max(0, unreadCount - 1);
} catch (err) {
error = err instanceof Error ? err.message : String(err);
}
}
async function markAllRead() {
if (markingAll || unreadCount === 0) return;
markingAll = true;
try {
await markAllNotificationsRead();
const now = new Date().toISOString();
notifications = notifications.map((item) => ({
...item,
readAt: item.readAt ?? now,
}));
unreadCount = 0;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
markingAll = false;
}
}
function formatDate(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
</script>
<button
type="button"
class="relative flex size-12 items-center justify-center rounded-[0.85rem] bg-black/20 text-theme-200 ring-1 ring-inset ring-white/15 transition-colors hover:bg-primary/20 hover:text-theme-50 disabled:cursor-not-allowed disabled:opacity-50"
title={$currentUser ? 'Notifications' : 'Log in to view notifications'}
aria-label={$currentUser
? `Notifications${unreadCount > 0 ? `, ${unreadCount} unread` : ''}`
: 'Notifications'}
disabled={!$currentUser}
onclick={openHub}
>
<Bell class="size-5" />
{#if unreadCount > 0}
<span
class="absolute -top-1 -right-1 flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[9px] font-bold text-primary-foreground ring-2 ring-theme-950"
>
{unreadLabel}
</span>
{/if}
</button>
{#if open}
<button
type="button"
class="fixed inset-0 z-[80] cursor-default bg-black/30 backdrop-blur-[1px]"
aria-label="Close notifications"
onclick={() => (open = false)}
></button>
<div
class="fixed bottom-4 left-[4.75rem] z-[90] flex max-h-[min(34rem,calc(100vh-2rem))] w-[22rem] flex-col overflow-hidden rounded-xl border border-theme-800 bg-theme-950 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="notification-hub-title"
>
<div class="flex items-start justify-between gap-3 border-b border-theme-800/70 p-4">
<div>
<h2 id="notification-hub-title" class="flex items-center gap-2 text-sm font-semibold text-white">
<Bell class="size-4 text-primary" />
Notifications
{#if unreadCount > 0}
<span class="rounded-full bg-primary/20 px-1.5 py-0.5 text-[10px] text-primary">
{unreadCount} new
</span>
{/if}
</h2>
<p class="mt-1 text-[11px] text-muted-foreground">Moderation updates and system messages.</p>
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
class="size-7 text-muted-foreground hover:text-white"
title="Refresh notifications"
onclick={() => void loadNotifications()}
disabled={loading}
>
<RefreshCw class="size-3.5 {loading ? 'animate-spin' : ''}" />
</Button>
<Button
variant="ghost"
size="icon"
class="size-7 text-muted-foreground hover:text-white"
title="Close"
onclick={() => (open = false)}
>
<X class="size-4" />
</Button>
</div>
</div>
<div class="flex items-center justify-between border-b border-theme-800/50 px-4 py-2">
<span class="text-[10px] text-muted-foreground">
{unreadCount > 0 ? `${unreadCount} unread` : 'All caught up'}
</span>
<Button
variant="ghost"
size="sm"
class="h-6 gap-1 px-1.5 text-[10px] text-muted-foreground hover:text-white"
onclick={() => void markAllRead()}
disabled={markingAll || unreadCount === 0}
>
{#if markingAll}
<LoaderCircle class="size-3 animate-spin" />
{:else}
<CheckCheck class="size-3" />
{/if}
Mark all read
</Button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-2">
{#if error}
<div class="m-2 rounded-lg border border-red-900/40 bg-red-950/20 p-3 text-[11px] text-red-300">
{error}
</div>
{/if}
{#if loading && notifications.length === 0}
<div class="flex h-40 items-center justify-center gap-2 text-xs text-muted-foreground">
<LoaderCircle class="size-4 animate-spin text-primary" /> Loading notifications...
</div>
{:else if notifications.length === 0}
<div class="flex h-40 flex-col items-center justify-center gap-2 px-6 text-center">
<Bell class="size-7 text-muted-foreground/40" />
<p class="text-xs text-muted-foreground">No notifications yet.</p>
<p class="text-[10px] text-muted-foreground/70">
Moderation decisions about your themes and reports will appear here.
</p>
</div>
{:else}
<div class="space-y-1">
{#each notifications as notification (notification.id)}
<button
type="button"
class="w-full rounded-lg border p-3 text-left transition-colors {notification.readAt
? 'border-transparent bg-transparent hover:bg-theme-900/50'
: 'border-primary/20 bg-primary/5 hover:bg-primary/10'}"
onclick={() => void markRead(notification)}
>
<div class="flex items-start gap-2.5">
<div
class="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full {notification.type ===
'theme_hidden' || notification.type === 'theme_taken_down'
? 'bg-red-500/10 text-red-300'
: notification.type === 'report_resolved'
? 'bg-emerald-500/10 text-emerald-300'
: notification.type === 'system'
? 'bg-cyan-500/10 text-cyan-300'
: 'bg-amber-500/10 text-amber-300'}"
>
{#if notification.type === 'system'}
<Megaphone class="size-3.5" />
{:else}
<ShieldAlert class="size-3.5" />
{/if}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<p class="text-[11px] font-semibold text-white">{notification.title}</p>
{#if !notification.readAt}
<span class="mt-1 size-1.5 shrink-0 rounded-full bg-primary"></span>
{/if}
</div>
<p class="mt-1 text-[10px] leading-relaxed text-muted-foreground">
{notification.message}
</p>
<p class="mt-1.5 text-[9px] text-muted-foreground/70">
{formatDate(notification.createdAt)}
</p>
</div>
</div>
</button>
{/each}
</div>
{#if hasMore}
<div class="flex justify-center p-2">
<Button
variant="ghost"
size="sm"
class="h-7 text-[10px] text-muted-foreground"
onclick={() => void loadOlder()}
disabled={loadingMore}
>
{#if loadingMore}
<LoaderCircle class="mr-1 size-3 animate-spin" />
{/if}
Load older
</Button>
</div>
{/if}
{/if}
</div>
</div>
{/if}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,29 @@
<script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip';
import type { Snippet } from 'svelte';
let {
content,
trigger,
side = 'top',
}: {
content: string;
trigger: Snippet<[Record<string, unknown>]>;
side?: 'top' | 'right' | 'bottom' | 'left';
} = $props();
</script>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
{@render trigger(props)}
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content
{side}
sideOffset={6}
class="max-w-72 border-theme-700 bg-theme-950 px-2.5 py-1.5 text-[10px] leading-relaxed text-white shadow-xl"
>
{content}
</Tooltip.Content>
</Tooltip.Root>
+100
View File
@@ -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<Record<string, string>> {
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<Error> {
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<NotificationsResponse> {
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<number> {
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<void> {
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<void> {
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 };
}
+4 -1
View File
@@ -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 @@
>
<ThemeIcon name="settings" set={iconSet} class="size-5!" />
</Button>
<div class="mt-auto">
<div class="mt-auto flex flex-col items-center gap-2">
<NotificationHub />
<DropdownMenu.Root>
<DropdownMenu.Trigger disabled={$launching}>
<Avatar.Root class="size-10">
@@ -3730,6 +3732,7 @@
<CommunityWorkshopView
onApplyTheme={applyInstalledTheme}
onRequestTrust={(theme) => requestThemeTrust(theme, false)}
onEditTheme={openThemeEditor}
/>
{:else}
<ScrollContainer class="pr-2">