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:
1 parent
1bf8034126
commit
9b4158410d
8 files changed
+3147
-232
No files matched your search
+3
-1
@@ -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 |
|
||||
| ----------------------- | ------------------------------------------------- | ---------------------------------------------- |
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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,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">
|
||||
|
||||
Reference in new issue
Block a user