Files
EZPPLauncher/src/lib/components/theme/CommunityWorkshopView.svelte
T
HorizonCode d555ac366f refactor(workshop): use the shared select component for theme filters
The sort, announcement audience and report category controls were still native select elements. They now use the same shadcn-svelte Select as the moderation filters for consistent styling and keyboard behavior.
2026-11-03 19:18:31 +01:00

3611 lines
139 KiB
Svelte

<script lang="ts">
import DefaultThemePreview from '$assets/default_preview.png';
import { activeThemeDownloads, themeDownloadProgress } from '$lib/communityThemeDownloads';
import {
fetchCommunityTags,
fetchCommunityThemes,
fetchCommunityThemeDetails,
toggleCommunityThemeLike,
downloadAndInstallCommunityTheme,
forkCommunityTheme,
deleteCommunityTheme,
permanentlyDeleteCommunityTheme,
restoreCommunityTheme,
setCommunityThemeVisibility,
setCommunityThemeQuarantine,
setCommunityThemeTags,
reportCommunityTheme,
fetchThemeReports,
fetchThemeReviewQueue,
reviewCommunityTheme,
resolveThemeReport,
bulkResolveThemeReports,
downloadThemeReportsCsv,
downloadModerationEventsCsv,
fetchModerationEvents,
type CommunityTagSummary,
type CommunityTheme,
type ModerationEvent,
type ModerationEventQuery,
type ThemeReport,
type ThemeReportCategory,
type ThemeReportQuery,
type ThemeReportStatus,
} from '$lib/communityThemes';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import Badge from '$lib/components/ui/badge/badge.svelte';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import DatePicker from '$lib/components/ui/date-picker/date-picker.svelte';
import * as Dialog from '$lib/components/ui/dialog';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import ScrollContainer from '$lib/components/ui/scroll-container/ScrollContainer.svelte';
import * as Select from '$lib/components/ui/select';
import * as Tooltip from '$lib/components/ui/tooltip';
import { active_custom_theme, custom_themes } from '$lib/global';
import { sendSystemAnnouncement } from '$lib/notifications';
import { isThemeTrusted } from '$lib/themeTrust';
import { normalizeThemeTags } from '$lib/themeV2';
import { reloadThemes, updateInstalledThemeTags, type Theme } from '$lib/themes';
import { currentUser } from '$lib/userAuthentication';
import WorkshopTooltip from './WorkshopTooltip.svelte';
import {
Archive,
Ban,
Check,
ChevronDown,
Code2,
CopyPlus,
Crown,
Download,
Ellipsis,
ExternalLink,
Eye,
EyeOff,
Flag,
GitFork,
Globe,
Heart,
History,
LoaderCircle,
Megaphone,
Pencil,
Plus,
RefreshCw,
RotateCcw,
Search,
ShieldCheck,
Sparkles,
Tag,
Trash2,
X,
} from '@lucide/svelte';
import { sileo } from 'sileo';
import { onMount } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
let {
onApplyTheme,
onRequestTrust,
onEditTheme,
}: {
onApplyTheme: (theme: Theme) => Promise<void>;
onRequestTrust: (theme: Theme) => void;
onEditTheme: (theme: Theme) => Promise<void>;
} = $props();
type WorkshopSort = 'relevance' | 'popular' | 'recent' | 'likes' | 'downloads';
type WorkshopView = 'workshop' | 'trash';
let searchQuery = $state('');
let selectedTag = $state('all');
let tagFilterOpen = $state(false);
let tagSearch = $state('');
let communityTags = $state<CommunityTagSummary[]>([]);
let tagsLoading = $state(false);
let tagsRequestId = 0;
let selectedSort = $state<WorkshopSort>('relevance');
let selectedView = $state<WorkshopView>('workshop');
let showHiddenOnly = $state(false);
let themes = $state<CommunityTheme[]>([]);
let totalThemes = $state(0);
let page = $state(1);
let totalPages = $state(1);
let loading = $state(false);
let loadingMore = $state(false);
let error = $state<string | null>(null);
let latestRequestId = 0;
let forkingId = $state<string | null>(null);
let deletingId = $state<string | null>(null);
let moderatingId = $state<string | null>(null);
let restoringId = $state<string | null>(null);
let purgingId = $state<string | null>(null);
let restoreTarget = $state<CommunityTheme | null>(null);
let purgeTarget = $state<CommunityTheme | null>(null);
let trashActionError = $state<string | null>(null);
let canModerate = $state(false);
let showModerationPanel = $state(false);
let moderationReports = $state<ThemeReport[]>([]);
let moderationEvents = $state<ModerationEvent[]>([]);
let moderationLoading = $state(false);
let moderationError = $state<string | null>(null);
let moderationTotal = $state(0);
let moderationPage = $state(1);
let moderationTotalPages = $state(1);
let moderationStatus = $state<ThemeReportStatus | 'all'>('open');
let moderationQuery = $state('');
let moderationAuthor = $state('');
let moderationCategory = $state<ThemeReportCategory | 'all'>('all');
let moderationFrom = $state('');
let moderationTo = $state('');
let moderationEventAction = $state('all');
let moderationEventActor = $state('');
let moderationEventsPage = $state(1);
let moderationEventsTotalPages = $state(1);
let reviewQueue = $state<CommunityTheme[]>([]);
let reviewTotal = $state(0);
let reviewActionId = $state<string | null>(null);
let selectedReportIds = $state<number[]>([]);
let reportActionId = $state<number | null>(null);
let bulkAction = $state<'hide' | 'resolved' | 'dismissed' | null>(null);
let exportingReports = $state(false);
let exportingEvents = $state(false);
let quarantineId = $state<string | null>(null);
let quarantineTarget = $state<CommunityTheme | null>(null);
let quarantineAction = $state<'take_down' | 'lift'>('take_down');
let quarantineReason = $state('');
let quarantineError = $state<string | null>(null);
let tagEditorTarget = $state<CommunityTheme | null>(null);
let tagEditorTags = $state<string[]>([]);
let tagEditorInput = $state('');
let tagEditorError = $state<string | null>(null);
let tagSaving = $state(false);
let announcementOpen = $state(false);
let announcementTitle = $state('');
let announcementMessage = $state('');
let announcementAudience = $state<'all' | 'staff'>('all');
let announcementSending = $state(false);
let announcementError = $state<string | null>(null);
let reportTarget = $state<CommunityTheme | null>(null);
let reportCategory = $state<ThemeReportCategory>('other');
let reportDetails = $state('');
let reportError = $state<string | null>(null);
let reportSubmitting = $state(false);
let historyOpen = $state(false);
let historyTarget = $state<{ id: string; name: string } | null>(null);
let historyEvents = $state<ModerationEvent[]>([]);
let historyPage = $state(1);
let historyTotalPages = $state(1);
let historyLoading = $state(false);
let historyError = $state<string | null>(null);
let sourceDialogOpen = $state(false);
let sourceTheme = $state<CommunityTheme | null>(null);
let sourceLoading = $state(false);
let sourceError = $state<string | null>(null);
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 SORT_OPTIONS: Array<{ id: WorkshopSort; label: string }> = [
{ id: 'relevance', label: 'Best match' },
{ id: 'popular', label: 'Most Popular' },
{ id: 'recent', label: 'Most Recent' },
{ id: 'likes', label: 'Most Liked' },
{ id: 'downloads', label: 'Most Downloaded' },
];
const REPORT_STATUS_OPTIONS: Array<{ id: ThemeReportStatus | 'all'; label: string }> = [
{ id: 'open', label: 'Open reports' },
{ id: 'resolved', label: 'Resolved reports' },
{ id: 'dismissed', label: 'Dismissed reports' },
{ id: 'all', label: 'All reports' },
];
const MODERATION_ACTIONS: Record<string, string> = {
hide: 'Hidden from the workshop',
restore: 'Visibility restored',
delete: 'Moved to trash',
restore_deleted: 'Restored from trash',
delete_permanent: 'Permanently deleted',
report: 'Reported',
report_resolved: 'Report resolved',
report_dismissed: 'Report dismissed',
quarantine: 'Taken down',
quarantine_restore: 'Takedown lifted',
review_approved: 'Theme approved',
review_rejected: 'Theme rejected',
};
const AUDIT_ACTIONS = [
{ id: 'all', label: 'All actions' },
{ 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: 'review_approved', label: 'Theme approved' },
{ id: 'review_rejected', label: 'Theme rejected' },
{ id: 'delete', label: 'Trashed' },
{ id: 'restore_deleted', label: 'Restored from trash' },
{ id: 'delete_permanent', label: 'Permanently deleted' },
];
const localThemeMap = $derived.by(() => {
const map = new SvelteMap<string, Theme>();
for (const t of $custom_themes) {
map.set(t.folder_name.toLowerCase(), t);
map.set(t.name.toLowerCase(), t);
}
return map;
});
const tagOptions = $derived.by(() => {
const counts = new SvelteMap<string, number>();
// The service returns every used tag. While it is unavailable or loading,
// fall back to tags visible on the current page so filtering still works.
if (communityTags.length > 0) {
for (const item of communityTags) counts.set(item.tag, item.count);
} else {
for (const theme of themes) {
for (const tag of theme.tags) counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
}
if (selectedTag !== 'all' && !counts.has(selectedTag)) counts.set(selectedTag, 0);
return [...counts.entries()]
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
});
const filteredTagOptions = $derived.by(() => {
const query = tagSearch.trim().toLowerCase();
return query ? tagOptions.filter((option) => option.tag.includes(query)) : tagOptions;
});
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 loadWorkshopTags() {
const requestId = ++tagsRequestId;
tagsLoading = true;
try {
const tags = await fetchCommunityTags();
if (requestId === tagsRequestId) communityTags = tags;
} catch (err) {
console.warn('[Workshop] Failed to load tag list:', err);
} finally {
if (requestId === tagsRequestId) tagsLoading = false;
}
}
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: selectedView === 'workshop' && selectedTag !== 'all' ? selectedTag : undefined,
sort: selectedSort,
page: targetPage,
limit: 24,
hidden: selectedView === 'workshop' && canModerate && showHiddenOnly ? true : undefined,
trashed: selectedView === 'trash' ? true : undefined,
});
// 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 {
if (requestId === latestRequestId) {
loading = false;
loadingMore = false;
}
}
}
let searchDebounceTimeout: ReturnType<typeof setTimeout> | undefined;
function handleSearchInput() {
clearTimeout(searchDebounceTimeout);
searchDebounceTimeout = setTimeout(() => {
void loadWorkshopThemes();
}, 300);
}
function handleTagClick(tag: string) {
tagFilterOpen = false;
tagSearch = '';
if (selectedTag === tag && selectedView === 'workshop') return;
selectedView = 'workshop';
selectedTag = tag;
void loadWorkshopThemes();
}
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;
void loadWorkshopThemes();
}
async function handleLike(theme: CommunityTheme, event: MouseEvent) {
event.stopPropagation();
if (theme.deletedAt) return;
const prevLiked = theme.isLiked;
const prevCount = theme.likeCount;
// Optimistic update
theme.isLiked = !prevLiked;
theme.likeCount = prevLiked ? Math.max(0, prevCount - 1) : prevCount + 1;
try {
const res = await toggleCommunityThemeLike(theme.id);
theme.isLiked = res.liked;
theme.likeCount = res.likeCount;
} catch (err) {
// Revert on error
theme.isLiked = prevLiked;
theme.likeCount = prevCount;
sileo.error({
title: 'Could not like theme',
description: err instanceof Error ? err.message : String(err),
});
}
}
async function handleDownloadTheme(theme: CommunityTheme) {
if (theme.deletedAt || activeThemeDownloads.has(theme.id)) return;
activeThemeDownloads.add(theme.id);
themeDownloadProgress.set(theme.id, 10);
try {
await downloadAndInstallCommunityTheme(theme, (_status, progress) => {
themeDownloadProgress.set(theme.id, progress);
});
sileo.success({
title: 'Theme downloaded',
description: `"${theme.name}" has been added to your library.`,
});
} catch (err) {
console.error(err);
sileo.error({
title: 'Download failed',
description: err instanceof Error ? err.message : String(err),
});
} finally {
activeThemeDownloads.delete(theme.id);
themeDownloadProgress.delete(theme.id);
}
}
async function handleApplyExisting(theme: CommunityTheme) {
if (theme.deletedAt) return;
const installed =
localThemeMap.get(theme.id.toLowerCase()) || localThemeMap.get(theme.name.toLowerCase());
if (!installed) {
await handleDownloadTheme(theme);
return;
}
if (!isThemeTrusted(installed)) {
onRequestTrust(installed);
} else {
await onApplyTheme(installed);
}
}
async function handleEditTheme(theme: CommunityTheme) {
if (
theme.deletedAt ||
activeThemeDownloads.has(theme.id) ||
forkingId ||
deletingId ||
moderatingId
)
return;
let installed =
localThemeMap.get(theme.id.toLowerCase()) || localThemeMap.get(theme.name.toLowerCase());
if (!installed) {
activeThemeDownloads.add(theme.id);
themeDownloadProgress.set(theme.id, 10);
try {
await downloadAndInstallCommunityTheme(theme, (_status, progress) => {
themeDownloadProgress.set(theme.id, 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 {
activeThemeDownloads.delete(theme.id);
themeDownloadProgress.delete(theme.id);
}
}
if (installed) {
await onEditTheme(installed);
}
}
async function handleRefreshOwnedTheme(theme: CommunityTheme) {
if (
theme.deletedAt ||
activeThemeDownloads.has(theme.id) ||
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;
activeThemeDownloads.add(theme.id);
themeDownloadProgress.set(theme.id, 10);
try {
const res = await downloadAndInstallCommunityTheme(theme, (_status, progress) => {
themeDownloadProgress.set(theme.id, 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 {
activeThemeDownloads.delete(theme.id);
themeDownloadProgress.delete(theme.id);
}
}
async function handleToggleVisibility(theme: CommunityTheme) {
if (!canModerate || moderatingId || 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;
}
}
function openTagEditor(theme: CommunityTheme) {
if (theme.deletedAt) return;
tagEditorTarget = theme;
tagEditorTags = normalizeThemeTags(theme.tags);
tagEditorInput = '';
tagEditorError = null;
}
function addTagEditorTag() {
const value = tagEditorInput.trim().toLowerCase();
if (!value) return;
if (tagEditorTags.includes(value)) {
tagEditorError = 'That tag is already added.';
return;
}
if (tagEditorTags.length >= 10) {
tagEditorError = 'A theme may have at most 10 tags.';
return;
}
const tags = normalizeThemeTags([...tagEditorTags, value]);
if (!tags.includes(value)) {
tagEditorError =
'Tags may contain lowercase letters, numbers, dashes and underscores (max 24 characters).';
return;
}
tagEditorTags = tags;
tagEditorInput = '';
tagEditorError = null;
}
function removeTagEditorTag(tag: string) {
tagEditorTags = tagEditorTags.filter((candidate) => candidate !== tag);
tagEditorError = null;
}
function closeTagEditor() {
if (tagSaving) return;
tagEditorTarget = null;
tagEditorTags = [];
tagEditorInput = '';
tagEditorError = null;
}
async function saveTagEditor() {
const target = tagEditorTarget;
if (!target || tagSaving) return;
const pending = tagEditorInput.trim().toLowerCase();
if (pending) {
if (tagEditorTags.includes(pending)) {
tagEditorError = 'That tag is already added.';
return;
}
if (tagEditorTags.length >= 10) {
tagEditorError = 'A theme may have at most 10 tags.';
return;
}
const withPending = normalizeThemeTags([...tagEditorTags, pending]);
if (!withPending.includes(pending)) {
tagEditorError =
'Tags may contain lowercase letters, numbers, dashes and underscores (max 24 characters).';
return;
}
tagEditorTags = withPending;
tagEditorInput = '';
}
const tags = normalizeThemeTags(tagEditorTags);
tagSaving = true;
tagEditorError = null;
try {
const updated = await setCommunityThemeTags(target.id, tags);
themes = themes.map((item) => (item.id === updated.id ? updated : item));
const installed =
localThemeMap.get(target.id.toLowerCase()) || localThemeMap.get(target.name.toLowerCase());
let localSyncError: string | null = null;
if (installed) {
try {
await updateInstalledThemeTags(installed.folder_name, updated.tags);
await reloadThemes();
} catch (err) {
localSyncError = err instanceof Error ? err.message : String(err);
}
}
if (localSyncError) {
sileo.warning({
title: 'Workshop tags updated',
description: `The installed theme.json could not be updated: ${localSyncError}`,
});
} else {
const tagSummary =
updated.tags.length > 0
? `Tags: ${updated.tags.map((tag) => `#${tag}`).join(', ')}`
: 'All tags were removed.';
sileo.success({
title: 'Theme tags updated',
description: installed
? `Workshop and installed theme.json tags updated. ${tagSummary}`
: `Workshop tags updated. ${tagSummary} They will be written into theme.json when the theme is installed.`,
});
}
tagEditorTarget = null;
tagEditorTags = [];
tagEditorInput = '';
await loadWorkshopThemes();
void loadWorkshopTags();
} catch (err) {
tagEditorError = err instanceof Error ? err.message : String(err);
} finally {
tagSaving = false;
}
}
async function handleForkTheme(theme: CommunityTheme) {
if (
theme.deletedAt ||
theme.isHidden ||
theme.allowForking === false ||
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();
activeThemeDownloads.add(fork.id);
themeDownloadProgress.set(fork.id, 10);
const res = await downloadAndInstallCommunityTheme(fork, (_status, progress) => {
themeDownloadProgress.set(fork.id, 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 {
activeThemeDownloads.delete(fork.id);
themeDownloadProgress.delete(fork.id);
}
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 || 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;
}
}
function openRestoreDialog(theme: CommunityTheme) {
if (restoringId || purgingId || forkingId || deletingId || moderatingId) return;
purgeTarget = null;
trashActionError = null;
restoreTarget = theme;
}
function openPermanentDeleteDialog(theme: CommunityTheme) {
if (restoringId || purgingId || forkingId || deletingId || moderatingId) return;
restoreTarget = null;
trashActionError = null;
purgeTarget = theme;
}
function closeRestoreDialog() {
if (restoringId) return;
restoreTarget = null;
trashActionError = null;
}
function closePermanentDeleteDialog() {
if (purgingId) return;
purgeTarget = null;
trashActionError = null;
}
async function handleRestoreTheme() {
const theme = restoreTarget;
if (!theme || restoringId || purgingId) return;
restoringId = theme.id;
trashActionError = null;
try {
await restoreCommunityTheme(theme.id);
themes = themes.filter((item) => item.id !== theme.id);
totalThemes = Math.max(0, totalThemes - 1);
restoreTarget = null;
sileo.success({
title: 'Theme restored',
description: `"${theme.name}" is back in the workshop.`,
});
void loadWorkshopThemes();
void loadWorkshopTags();
} catch (err) {
trashActionError = err instanceof Error ? err.message : String(err);
} finally {
restoringId = null;
}
}
async function handlePermanentDeleteTheme() {
const theme = purgeTarget;
if (!theme || restoringId || purgingId) return;
purgingId = theme.id;
trashActionError = null;
try {
await permanentlyDeleteCommunityTheme(theme.id);
themes = themes.filter((item) => item.id !== theme.id);
totalThemes = Math.max(0, totalThemes - 1);
purgeTarget = null;
sileo.success({
title: 'Theme permanently deleted',
description: `"${theme.name}" and its server files were removed.`,
});
void loadWorkshopThemes();
void loadWorkshopTags();
} catch (err) {
trashActionError = err instanceof Error ? err.message : String(err);
} finally {
purgingId = 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, reviewResult] = await Promise.all([
fetchThemeReports({ ...currentReportFilters(), page, limit: 50 }),
fetchModerationEvents({ ...currentEventFilters(), page: 1, limit: 50 }),
fetchThemeReviewQueue('pending', 1, 50),
]);
moderationReports = reportsResult.reports;
reviewQueue = reviewResult.items;
reviewTotal = reviewResult.total;
moderationTotal = reportsResult.total;
moderationPage = reportsResult.page;
moderationTotalPages = reportsResult.totalPages;
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);
}
async function handleReviewTheme(theme: CommunityTheme, status: 'approved' | 'rejected') {
if (reviewActionId) return;
let note: string | undefined;
if (status === 'rejected') {
const answer = window.prompt(
`Reject "${theme.name}"? Add an optional note for the creator:`,
''
);
if (answer === null) return;
note = answer.trim() || undefined;
}
reviewActionId = theme.id;
try {
await reviewCommunityTheme(theme.id, status, note);
sileo.success({
title: status === 'approved' ? 'Theme approved' : 'Theme rejected',
description:
status === 'approved'
? `"${theme.name}" is now listed in the Community Workshop.`
: `"${theme.name}" stays hidden until the creator republishes it.`,
});
await loadModerationPanel(moderationPage);
void loadWorkshopThemes();
} catch (err) {
sileo.error({
title: 'Could not review theme',
description: err instanceof Error ? err.message : String(err),
});
} finally {
reviewActionId = null;
}
}
function handleAuditFilterChange() {
moderationEventsPage = 1;
selectedReportIds = [];
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;
}
}
function handleToggleQuarantine(theme: CommunityTheme) {
if (!canModerate || quarantineId) return;
quarantineTarget = theme;
quarantineAction = theme.isQuarantined ? 'lift' : 'take_down';
quarantineReason = '';
quarantineError = null;
}
function closeQuarantineDialog() {
if (quarantineId) return;
quarantineTarget = null;
quarantineReason = '';
quarantineError = null;
}
async function confirmQuarantine() {
const theme = quarantineTarget;
if (!theme || quarantineId) return;
const takingDown = quarantineAction === 'take_down';
const reason = quarantineReason.trim();
if (takingDown && reason.length < 3) {
quarantineError = 'Please provide a reason of at least 3 characters.';
return;
}
quarantineId = theme.id;
quarantineError = null;
try {
await setCommunityThemeQuarantine(theme.id, takingDown, reason || undefined);
sileo.success({
title: takingDown ? 'Theme taken down' : 'Takedown lifted',
description: `"${theme.name}" was updated.`,
});
quarantineTarget = null;
quarantineReason = '';
await loadWorkshopThemes();
if (showModerationPanel) await loadModerationPanel(moderationPage);
} catch (err) {
quarantineError = err instanceof Error ? err.message : String(err);
} finally {
quarantineId = null;
}
}
function openAnnouncementComposer() {
// The moderation panel is a modal Dialog; close it before opening another overlay.
showModerationPanel = false;
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) {
// A custom modal cannot stack reliably over a portaled modal Dialog.
showModerationPanel = false;
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 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;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
}
onMount(() => {
void loadWorkshopThemes();
void loadWorkshopTags();
});
</script>
<Tooltip.Provider delayDuration={220}>
<div class="flex h-full flex-col overflow-hidden">
<!-- Workshop Filter & Search Toolbar -->
<div class="flex flex-col gap-2.5 pb-3">
<div class="flex items-center justify-between gap-3">
<!-- Tag Chips / Trash label -->
<div class="flex min-w-0 flex-wrap items-center gap-1">
{#if selectedView === 'trash'}
<span
class="flex items-center gap-1 rounded-full border border-red-900/40 bg-red-950/40 px-2.5 py-1 text-[11px] font-medium text-red-300"
>
<Archive class="size-3" />
Your trash
</span>
{:else}
<button
type="button"
class="rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors {selectedTag ===
'all'
? 'bg-primary text-primary-foreground shadow-sm'
: 'bg-theme-900/60 text-muted-foreground hover:bg-theme-800/60 hover:text-white'}"
onclick={() => handleTagClick('all')}
>
All Tags
</button>
<Popover.Root bind:open={tagFilterOpen}>
<Popover.Trigger
class="flex h-7 max-w-44 items-center gap-1.5 rounded-full border px-2.5 text-[11px] font-medium transition-colors {selectedTag ===
'all'
? 'border-theme-800 bg-theme-900/60 text-muted-foreground hover:bg-theme-800/60 hover:text-white'
: 'border-primary/40 bg-primary/15 text-primary'}"
aria-label="Filter workshop by tag"
>
<Tag class="size-3 shrink-0" />
<span class="truncate">
{selectedTag === 'all' ? 'Filter by tag' : `#${selectedTag}`}
</span>
{#if tagOptions.length > 0}
<span
class="shrink-0 rounded bg-theme-800/80 px-1 text-[9px] tabular-nums text-muted-foreground"
>
{tagOptions.length}
</span>
{/if}
{#if tagsLoading}
<LoaderCircle class="size-3 shrink-0 animate-spin" />
{:else}
<ChevronDown class="size-3 shrink-0 opacity-60" />
{/if}
</Popover.Trigger>
<Popover.Content
align="start"
class="w-72 border-theme-800 bg-theme-950 p-0 text-theme-50 shadow-2xl"
>
<div class="border-b border-theme-800/70 p-2">
<div class="relative">
<Search
class="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground"
/>
<Input
bind:value={tagSearch}
class="h-8 border-theme-800 bg-theme-900/60 pr-8 pl-8 text-[11px]"
placeholder="Search used tags..."
autofocus
/>
{#if tagSearch}
<button
type="button"
class="absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground transition-colors hover:text-white"
aria-label="Clear tag search"
onclick={() => (tagSearch = '')}
>
<X class="size-3.5" />
</button>
{/if}
</div>
</div>
<div class="max-h-64 overflow-y-auto p-1">
<button
type="button"
class="flex w-full items-center justify-between rounded px-2 py-1.5 text-[11px] transition-colors {selectedTag ===
'all'
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:bg-theme-800/60 hover:text-white'}"
onclick={() => handleTagClick('all')}
>
<span class="flex items-center gap-2"><Tag class="size-3" /> All tags</span>
{#if selectedTag === 'all'}<Check class="size-3" />{/if}
</button>
{#if filteredTagOptions.length === 0}
<p class="px-2 py-4 text-center text-[10px] text-muted-foreground">
{tagOptions.length === 0
? 'No tags are in use yet.'
: 'No tags match your search.'}
</p>
{:else}
{#each filteredTagOptions as option (option.tag)}
<button
type="button"
class="flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-[11px] transition-colors {selectedTag ===
option.tag
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:bg-theme-800/60 hover:text-white'}"
onclick={() => handleTagClick(option.tag)}
>
<span class="truncate">#{option.tag}</span>
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground">
{option.count}
</span>
</button>
{/each}
{/if}
</div>
</Popover.Content>
</Popover.Root>
{#if canModerate}
<WorkshopTooltip content="Show only hidden themes">
{#snippet trigger(props)}
<button
{...props}
type="button"
class="flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors {showHiddenOnly
? 'bg-amber-500/20 text-amber-200 ring-1 ring-amber-500/40'
: 'bg-theme-900/60 text-muted-foreground hover:bg-theme-800/60 hover:text-white'}"
onclick={handleHiddenFilter}
>
<EyeOff class="size-3" />
Hidden
</button>
{/snippet}
</WorkshopTooltip>
{/if}
{/if}
</div>
<!-- Controls Right: Sort, Search, Moderation, Trash & Refresh -->
<div class="flex shrink-0 items-center gap-2">
<Select.Root
type="single"
value={selectedSort}
onValueChange={(value) => {
if (!value) return;
handleSortChange(parseWorkshopSort(value));
}}
>
<Select.Trigger
class="h-7.5 w-36 border-theme-800 bg-theme-950/80 px-2.5 text-[11px] text-muted-foreground transition-colors hover:text-white"
aria-label="Sort themes"
>
{SORT_OPTIONS.find((option) => option.id === selectedSort)?.label ?? 'Best match'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
{#each SORT_OPTIONS as option (option.id)}
<Select.Item value={option.id} label={option.label} />
{/each}
</Select.Content>
</Select.Root>
<div class="relative w-48">
<Search
class="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground"
/>
<WorkshopTooltip
content="Typed search: tag:anime, author:name, name:night, id:my-theme"
>
{#snippet trigger(props)}
<Input
{...props}
type="text"
class="h-7.5 border-theme-800 bg-theme-950/80 pl-8 text-[11px]"
placeholder={selectedView === 'trash'
? 'Search trash...'
: 'Search themes, tags, authors...'}
bind:value={searchQuery}
oninput={handleSearchInput}
/>
{/snippet}
</WorkshopTooltip>
</div>
{#if canModerate}
<WorkshopTooltip content="Moderation queue and audit history">
{#snippet trigger(props)}
<Button
{...props}
variant="ghost"
size="icon"
class="size-7.5 text-muted-foreground hover:text-amber-300"
onclick={openModerationPanel}
>
<Flag class="size-3.5" />
</Button>
{/snippet}
</WorkshopTooltip>
{/if}
{#if $currentUser}
<WorkshopTooltip content={selectedView === 'trash' ? 'Back to workshop' : 'Open trash'}>
{#snippet trigger(props)}
<Button
{...props}
variant={selectedView === 'trash' ? 'secondary' : 'ghost'}
size="icon"
class="size-7.5 text-muted-foreground hover:text-white"
onclick={() => handleViewChange(selectedView === 'trash' ? 'workshop' : 'trash')}
>
<Archive class="size-3.5" />
</Button>
{/snippet}
</WorkshopTooltip>
{/if}
<WorkshopTooltip content="Refresh Workshop">
{#snippet trigger(props)}
<Button
{...props}
variant="ghost"
size="icon"
class="size-7.5 text-muted-foreground hover:text-white"
onclick={() => {
void loadWorkshopThemes();
void loadWorkshopTags();
}}
disabled={loading}
>
<RefreshCw class="size-3.5 {loading ? 'animate-spin' : ''}" />
</Button>
{/snippet}
</WorkshopTooltip>
</div>
</div>
</div>
<!-- Content Grid / Status -->
<div class="min-h-0 flex-1 pt-1">
<ScrollContainer class="pr-2">
{#if loading && themes.length === 0}
<div class="flex h-64 flex-col items-center justify-center gap-3 text-muted-foreground">
<LoaderCircle class="size-6 animate-spin text-primary" />
<p class="text-xs">Connecting to EZPPFarm Community Workshop...</p>
</div>
{:else if error && themes.length === 0}
<div class="flex h-64 flex-col items-center justify-center gap-3 text-center">
<div class="rounded-full bg-red-500/10 p-3 text-red-400">
<Globe class="size-6" />
</div>
<div class="max-w-md">
<p class="text-sm font-semibold text-white">Workshop Offline or Unreachable</p>
<p class="mt-1 text-xs text-muted-foreground">
{error}. Ensure the themes microservice container is running.
</p>
</div>
<Button
variant="secondary"
size="sm"
class="mt-2 text-xs"
onclick={() => void loadWorkshopThemes()}
>
<RefreshCw class="mr-1.5 size-3.5" />
Try Again
</Button>
</div>
{:else if themes.length === 0}
<div class="flex h-64 flex-col items-center justify-center gap-2 text-muted-foreground">
<Globe class="size-8 opacity-40" />
<p class="text-xs">No community themes found matching your search.</p>
</div>
{:else}
<div class="grid grid-cols-3 gap-3 pb-4">
{#each themes as theme (theme.id)}
{@const localTheme =
localThemeMap.get(theme.id.toLowerCase()) ||
localThemeMap.get(theme.name.toLowerCase())}
{@const isInstalled = Boolean(localTheme)}
{@const isActive = Boolean(
localTheme && $active_custom_theme?.folder_name === localTheme.folder_name
)}
{@const isDownloading = activeThemeDownloads.has(theme.id)}
{@const downloadPercent = themeDownloadProgress.get(theme.id) ?? 0}
{@const isDeleted = Boolean(theme.deletedAt)}
{@const hasUpdate = Boolean(
!isDeleted && localTheme && localTheme.version !== theme.version
)}
{@const owned = isOwnedByCurrentUser(theme)}
{@const isLegacy =
theme.apiVersion === '1.0' || Boolean(theme.tags?.includes('legacy'))}
{@const canReport =
Boolean($currentUser) && !owned && !theme.isHidden && !theme.isQuarantined}
{@const canFork = canReport && !isLegacy && theme.allowForking !== false}
{@const showOverflowMenu = !isDeleted && (owned || canModerate || canReport)}
<div
class="group flex flex-col overflow-hidden rounded-xl border bg-theme-900/30 {isActive
? 'border-primary/60 ring-1 ring-primary/20'
: 'border-theme-800/60'} transition-all duration-300 hover:-translate-y-0.5 hover:border-white/20 hover:bg-theme-900/50 hover:shadow-xl hover:shadow-black/30"
>
<!-- 16:9 Thumbnail -->
<div class="relative aspect-video w-full shrink-0 overflow-hidden bg-theme-950/70">
<img
src={theme.previewUrl}
alt={theme.name}
class="h-full w-full object-cover object-center transition-transform duration-500 group-hover:scale-[1.03]"
onerror={(e) => {
// Fallback to default theme preview if server preview not loaded
(e.currentTarget as HTMLImageElement).src = DefaultThemePreview;
}}
/>
<div
class="pointer-events-none absolute inset-0 bg-gradient-to-t from-theme-950/90 via-transparent to-transparent"
></div>
<!-- Top Left: Trust Badge -->
<div class="absolute top-2 left-2 flex items-center gap-1.5">
{#if theme.apiVersion === '1.0' || theme.tags?.includes('legacy')}
<WorkshopTooltip content="Legacy v1 theme: video/audio playback">
{#snippet trigger(props)}
<span
{...props}
tabindex="-1"
class="flex items-center gap-1 rounded-md border border-purple-500/40 bg-purple-950/80 px-1.5 py-0.5 text-[9px] font-medium text-purple-300 backdrop-blur-sm"
>
<Sparkles class="size-3 text-purple-300" />
Legacy v1
</span>
{/snippet}
</WorkshopTooltip>
{:else if theme.hasExecutableCode}
<WorkshopTooltip content="Contains custom Svelte 5 executable logic">
{#snippet trigger(props)}
<span
{...props}
tabindex="-1"
class="flex items-center gap-1 rounded-md border border-amber-500/30 bg-amber-950/80 px-1.5 py-0.5 text-[9px] font-medium text-amber-300 backdrop-blur-sm"
>
<Code2 class="size-3" />
Svelte Code
</span>
{/snippet}
</WorkshopTooltip>
{/if}
{#if theme.reviewStatus === 'pending'}
<WorkshopTooltip
content="Waiting for staff review before it is listed publicly"
>
{#snippet trigger(props)}
<span
{...props}
tabindex="-1"
class="flex items-center gap-1 rounded-md border border-amber-500/40 bg-amber-950/85 px-1.5 py-0.5 text-[9px] font-medium text-amber-200 backdrop-blur-sm"
>
<LoaderCircle class="size-3" />
In review
</span>
{/snippet}
</WorkshopTooltip>
{:else if theme.reviewStatus === 'rejected'}
<WorkshopTooltip
content={`Not approved${theme.reviewNote ? `: ${theme.reviewNote}` : ''}`}
>
{#snippet trigger(props)}
<span
{...props}
tabindex="-1"
class="flex items-center gap-1 rounded-md border border-red-500/40 bg-red-950/85 px-1.5 py-0.5 text-[9px] font-medium text-red-200 backdrop-blur-sm"
>
<X class="size-3" />
Rejected
</span>
{/snippet}
</WorkshopTooltip>
{/if}
{#if theme.isHidden}
<WorkshopTooltip
content={`${theme.hiddenReason || 'Hidden by a moderator'}${
theme.hiddenByName ? ` · by ${theme.hiddenByName}` : ''
}${theme.hiddenAt ? ` · ${formatModerationDate(theme.hiddenAt)}` : ''}`}
>
{#snippet trigger(props)}
<span
{...props}
tabindex="-1"
class="flex items-center gap-1 rounded-md border border-red-500/40 bg-red-950/85 px-1.5 py-0.5 text-[9px] font-medium text-red-300 backdrop-blur-sm"
>
<EyeOff class="size-3" />
Hidden
</span>
{/snippet}
</WorkshopTooltip>
{/if}
{#if theme.isQuarantined}
<WorkshopTooltip
content={`${theme.quarantineReason || 'Taken down by staff'}${
theme.quarantinedByName ? ` · by ${theme.quarantinedByName}` : ''
}${theme.quarantinedAt ? ` · ${formatModerationDate(theme.quarantinedAt)}` : ''}`}
>
{#snippet trigger(props)}
<span
{...props}
tabindex="-1"
class="flex items-center gap-1 rounded-md border border-red-500/50 bg-red-950/90 px-1.5 py-0.5 text-[9px] font-medium text-red-200 backdrop-blur-sm"
>
<Ban class="size-3" />
Taken down
</span>
{/snippet}
</WorkshopTooltip>
{/if}
{#if isDeleted}
<WorkshopTooltip content="This theme is in your trash">
{#snippet trigger(props)}
<span
{...props}
tabindex="-1"
class="flex items-center gap-1 rounded-md border border-red-500/40 bg-red-950/85 px-1.5 py-0.5 text-[9px] font-medium text-red-300 backdrop-blur-sm"
>
<Trash2 class="size-3" />
Trash
</span>
{/snippet}
</WorkshopTooltip>
{/if}
</div>
<!-- Top Right: Version -->
<div class="absolute top-2 right-2">
<span
class="rounded-md bg-black/60 px-1.5 py-0.5 text-[9px] font-medium text-white/90 backdrop-blur-sm"
>
v{theme.version}
</span>
</div>
<!-- Bottom: Author Info -->
<div
class="absolute right-2 bottom-2 left-2 flex items-center justify-between gap-2"
>
<div class="flex min-w-0 items-center gap-1.5">
<img
src={theme.author.avatarUrl}
alt={theme.author.name}
class="size-5 rounded-full border border-white/20 bg-theme-900 object-cover"
onerror={(e) => {
(e.currentTarget as HTMLElement).style.display = 'none';
}}
/>
<span class="truncate text-[11px] font-semibold text-white/95 drop-shadow-sm">
{theme.author.name}
</span>
{#if theme.author.role === 'owner'}
<WorkshopTooltip content="EZPPFarm owner">
{#snippet trigger(props)}
<span
{...props}
tabindex="-1"
class="flex items-center gap-0.5 rounded-full border border-rose-500/30 bg-rose-500/20 px-1 py-0.5 text-[8px] font-bold text-rose-200"
>
<Crown class="size-2.5 fill-rose-300" />
Owner
</span>
{/snippet}
</WorkshopTooltip>
{:else if theme.author.role === 'moderator'}
<WorkshopTooltip content="Community Workshop moderator">
{#snippet trigger(props)}
<span
{...props}
tabindex="-1"
class="flex items-center gap-0.5 rounded-full border border-cyan-500/30 bg-cyan-500/20 px-1 py-0.5 text-[8px] font-bold text-cyan-200"
>
<ShieldCheck class="size-2.5" />
Moderator
</span>
{/snippet}
</WorkshopTooltip>
{:else if theme.author.role === 'supporter' || (!theme.author.role && theme.author.donor)}
<WorkshopTooltip content="EZPPFarm Supporter">
{#snippet trigger(props)}
<span
{...props}
tabindex="-1"
class="flex items-center gap-0.5 rounded-full border border-amber-500/30 bg-amber-500/20 px-1 py-0.5 text-[8px] font-bold text-amber-300"
>
<Sparkles class="size-2.5 fill-amber-300" />
Supporter
</span>
{/snippet}
</WorkshopTooltip>
{/if}
</div>
<span class="text-[9px] text-muted-foreground/90 tabular-nums">
{formatBytes(theme.fileSize)}
</span>
</div>
</div>
<!-- Body -->
<div class="flex flex-1 flex-col justify-between gap-2 p-3">
<div class="min-w-0">
<div class="flex items-center justify-between gap-2">
<h3
class="truncate text-[13px] font-bold text-white group-hover:text-primary transition-colors"
>
{theme.name}
</h3>
<!-- Interactive Like Button -->
{#if isDeleted}
<span
class="flex items-center gap-1 rounded-full bg-red-950/40 px-2 py-0.5 text-[10px] text-red-300"
>
<Trash2 class="size-3" /> Deleted
</span>
{:else}
<WorkshopTooltip content={theme.isLiked ? 'Unlike' : 'Like'}>
{#snippet trigger(props)}
<button
{...props}
type="button"
class="flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] transition-colors {theme.isLiked
? 'bg-rose-500/20 text-rose-300 hover:bg-rose-500/30'
: 'bg-theme-800/40 text-muted-foreground hover:bg-theme-800/80 hover:text-white'}"
onclick={(e) => handleLike(theme, e)}
>
<Heart
class="size-3 {theme.isLiked ? 'fill-rose-400 text-rose-400' : ''}"
/>
<span class="tabular-nums text-[10px]">{theme.likeCount}</span>
</button>
{/snippet}
</WorkshopTooltip>
{/if}
</div>
{#if theme.description}
<p
class="mt-1 line-clamp-2 text-[10px] leading-relaxed text-muted-foreground"
>
{theme.description}
</p>
{/if}
{#if theme.tags && theme.tags.length > 0}
<div class="mt-2 flex flex-wrap gap-1">
{#each theme.tags.slice(0, 3) as tag (tag)}
<button
type="button"
class="cursor-pointer rounded px-1.5 py-0.5 text-[9px] transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/60 {selectedTag ===
tag
? 'bg-primary/20 text-primary'
: 'bg-theme-800/50 text-muted-foreground hover:bg-theme-800 hover:text-white'}"
title={`Filter workshop by #${tag}`}
aria-label={`Filter workshop by #${tag}`}
aria-pressed={selectedTag === tag}
onclick={(event) => {
event.stopPropagation();
handleTagClick(tag);
}}
>
#{tag}
</button>
{/each}
</div>
{/if}
{#if theme.isHidden || theme.isQuarantined}
<div
class="mt-2 space-y-1 rounded-md border border-red-900/40 bg-red-950/15 px-2 py-1.5 text-[9px] leading-relaxed"
>
{#if theme.isHidden}
<p class="text-red-200/90">
<EyeOff class="mr-1 inline size-3 align-[-2px]" />
Hidden by {theme.hiddenByName ?? 'staff'}
{#if theme.hiddenAt}
· {formatModerationDate(theme.hiddenAt)}
{/if}
</p>
{#if theme.hiddenReason}
<p class="text-muted-foreground">{theme.hiddenReason}</p>
{/if}
{/if}
{#if theme.isQuarantined}
<p class="text-red-200/90">
<Ban class="mr-1 inline size-3 align-[-2px]" />
Taken down by {theme.quarantinedByName ?? 'staff'}
{#if theme.quarantinedAt}
· {formatModerationDate(theme.quarantinedAt)}
{/if}
</p>
{#if theme.quarantineReason}
<p class="text-muted-foreground">{theme.quarantineReason}</p>
{/if}
{/if}
</div>
{/if}
{#if theme.forkedFrom}
{@const forkedFrom = theme.forkedFrom}
<WorkshopTooltip
content={`Open ${forkedFrom.name} by ${forkedFrom.authorName}`}
>
{#snippet trigger(props)}
<button
{...props}
type="button"
class="mt-2 flex max-w-full min-w-0 items-center gap-1 text-left text-[9px] text-muted-foreground transition-colors hover:text-white"
onclick={() => handleOpenForkSource(theme)}
>
<GitFork class="size-3 shrink-0" />
<span class="truncate">
Forked from <span class="text-white/75">{forkedFrom.name}</span>
by {forkedFrom.authorName}
</span>
<ExternalLink class="size-2.5 shrink-0 opacity-60" />
</button>
{/snippet}
</WorkshopTooltip>
{/if}
</div>
<!-- Footer Action Row -->
<div
class="mt-2 flex flex-wrap items-center justify-between gap-2 border-t border-theme-800/40 pt-2.5"
>
<div class="flex min-w-0 items-center gap-2 text-[10px] text-muted-foreground">
<span class="inline-flex items-center gap-1">
<Download class="size-3" />
<span class="tabular-nums">{theme.downloadCount}</span>
</span>
{#if isInstalled && !isActive}
<span
class="rounded-full bg-theme-800/50 px-1.5 py-0.5 text-[9px] text-muted-foreground"
>
Installed
</span>
{/if}
</div>
<div class="flex shrink-0 items-center gap-1">
{#if isDownloading}
<div class="flex items-center gap-2">
<span class="text-[10px] text-primary tabular-nums"
>{downloadPercent}%</span
>
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-theme-800">
<div
class="h-full bg-primary transition-all duration-200"
style="width: {downloadPercent}%"
></div>
</div>
</div>
{:else if deletingId === theme.id}
<span class="flex items-center gap-1 text-[10px] text-red-300">
<LoaderCircle class="size-3 animate-spin" /> Deleting
</span>
{:else if forkingId === theme.id}
<span class="flex items-center gap-1 text-[10px] text-cyan-300">
<LoaderCircle class="size-3 animate-spin" /> Forking
</span>
{:else if moderatingId === theme.id}
<span class="flex items-center gap-1 text-[10px] text-amber-300">
<LoaderCircle class="size-3 animate-spin" /> Updating
</span>
{:else if isDeleted}
{#if owned}
<Button
variant="secondary"
size="sm"
class="h-7 gap-1 text-xs"
onclick={() => openRestoreDialog(theme)}
disabled={restoringId === theme.id || purgingId === theme.id}
>
{#if restoringId === theme.id}
<LoaderCircle class="size-3 animate-spin" />
Restoring
{:else}
<RotateCcw class="size-3" />
Restore
{/if}
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 gap-1 text-xs text-red-300 hover:bg-red-500/10 hover:text-red-200"
onclick={() => openPermanentDeleteDialog(theme)}
disabled={restoringId === theme.id || purgingId === theme.id}
>
{#if purgingId === theme.id}
<LoaderCircle class="size-3 animate-spin" />
Deleting...
{:else}
<Trash2 class="size-3" />
Delete forever
{/if}
</Button>
{:else}
<Badge
variant="outline"
class="border-red-500/40 bg-red-950/20 text-red-300 text-[10px]"
>
<Trash2 class="mr-1 size-3" /> Deleted
</Badge>
{/if}
{:else}
{#if owned && !isLegacy && isInstalled}
<Button
size="sm"
class="h-7 gap-1 text-xs"
onclick={() => handleEditTheme(theme)}
>
<Pencil class="size-3" />
Edit
</Button>
{:else if isActive}
<Badge
variant="outline"
class="border-emerald-500/40 bg-emerald-950/20 text-emerald-300 text-[10px]"
>
<Check class="mr-1 size-3" /> In use
</Badge>
{:else if isInstalled && hasUpdate}
<Button
size="sm"
class="h-7 gap-1 text-xs bg-cyan-600 hover:bg-cyan-500 text-white font-medium"
onclick={() => handleDownloadTheme(theme)}
>
<Download class="size-3" />
Update
</Button>
{:else if isInstalled}
<Button
variant="secondary"
size="sm"
class="h-7 text-xs"
onclick={() => handleApplyExisting(theme)}
>
Apply
</Button>
{:else}
<Button
size="sm"
class="h-7 gap-1 text-xs"
onclick={() => handleDownloadTheme(theme)}
>
<Download class="size-3" />
Download
</Button>
{/if}
{#if showOverflowMenu}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="ghost"
size="icon"
class="size-7 text-muted-foreground hover:text-white"
aria-label="More actions for {theme.name}"
>
<Ellipsis class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
align="end"
class="w-52 border-theme-700 bg-theme-950 text-theme-50"
>
{#if owned}
<DropdownMenu.Item
class="gap-2 text-[11px]"
onclick={() => openTagEditor(theme)}
>
<Tag class="size-3.5" />
Edit tags
</DropdownMenu.Item>
{#if !isLegacy && isInstalled && hasUpdate}
<DropdownMenu.Item
class="gap-2 text-[11px]"
onclick={() => handleRefreshOwnedTheme(theme)}
>
<RefreshCw class="size-3.5" />
Refresh installed copy
</DropdownMenu.Item>
{/if}
{:else if canReport || canFork}
{#if canReport}
<DropdownMenu.Item
class="gap-2 text-[11px]"
onclick={() => openReportDialog(theme)}
>
<Flag class="size-3.5" />
Report theme
</DropdownMenu.Item>
{/if}
{#if canFork}
<DropdownMenu.Item
class="gap-2 text-[11px]"
onclick={() => handleForkTheme(theme)}
>
<CopyPlus class="size-3.5" />
Fork theme
</DropdownMenu.Item>
{/if}
{/if}
{#if canModerate}
{#if owned || canReport}
<DropdownMenu.Separator class="my-1 bg-theme-800" />
{/if}
{#if !isLegacy}
<DropdownMenu.Item
class="gap-2 text-[11px]"
onclick={() => handleToggleVisibility(theme)}
>
{#if theme.isHidden}
<Eye class="size-3.5" />
Restore visibility
{:else}
<EyeOff class="size-3.5" />
Hide from workshop
{/if}
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item
class="gap-2 text-[11px]"
onclick={() => handleToggleQuarantine(theme)}
disabled={quarantineId === theme.id}
>
{#if quarantineId === theme.id}
<LoaderCircle class="size-3.5 animate-spin" />
Updating...
{:else}
<Ban class="size-3.5" />
{theme.isQuarantined ? 'Lift takedown' : 'Take down theme'}
{/if}
</DropdownMenu.Item>
<DropdownMenu.Item
class="gap-2 text-[11px]"
onclick={() => void openThemeHistory(theme.id, theme.name)}
>
<History class="size-3.5" />
Moderation history
</DropdownMenu.Item>
{/if}
{#if owned}
<DropdownMenu.Separator class="my-1 bg-theme-800" />
<DropdownMenu.Item
class="gap-2 text-[11px] text-red-300 data-[highlighted]:bg-red-500/15 data-[highlighted]:text-red-200"
onclick={() => handleDeleteTheme(theme)}
>
<Trash2 class="size-3.5" />
Move to trash
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
{/if}
</div>
</div>
</div>
</div>
{/each}
</div>
{#if page < totalPages}
<div class="flex items-center justify-center pb-4">
<Button
variant="secondary"
size="sm"
class="h-8 gap-1.5 text-xs"
onclick={handleLoadMore}
disabled={loadingMore}
>
{#if loadingMore}
<LoaderCircle class="size-3.5 animate-spin" />
Loading...
{:else}
<ChevronDown class="size-3.5" />
Load more ({themes.length} of {totalThemes})
{/if}
</Button>
</div>
{/if}
{/if}
</ScrollContainer>
</div>
</div>
<Dialog.Root bind:open={showModerationPanel}>
<Dialog.Content
class="top-12 bottom-4 flex h-auto w-[calc(100%_-_2rem)] max-w-6xl translate-y-0 flex-col gap-0 overflow-hidden rounded-xl border-theme-800 bg-theme-950 p-0 text-white shadow-2xl sm:rounded-xl"
>
<div class="flex items-start justify-between gap-3 border-b border-theme-800/70 p-4">
<div>
<Dialog.Title class="flex items-center gap-2 text-sm font-semibold text-white">
<Flag class="size-4 text-amber-300" />
Moderation
</Dialog.Title>
<Dialog.Description class="mt-1 text-[11px] text-muted-foreground">
Themes awaiting review, open reports and recent staff activity.
</Dialog.Description>
</div>
<div class="flex items-center gap-1">
<WorkshopTooltip content="Send a system announcement">
{#snippet trigger(props)}
<Button
{...props}
variant="ghost"
size="icon"
class="size-7 text-muted-foreground hover:text-cyan-300"
onclick={openAnnouncementComposer}
aria-label="Send a system announcement"
>
<Megaphone class="size-3.5" />
</Button>
{/snippet}
</WorkshopTooltip>
<WorkshopTooltip content="Refresh moderation data">
{#snippet trigger(props)}
<Button
{...props}
variant="ghost"
size="icon"
class="size-7 text-muted-foreground hover:text-white"
onclick={() => void loadModerationPanel()}
disabled={moderationLoading}
aria-label="Refresh moderation data"
>
<RefreshCw class="size-3.5 {moderationLoading ? 'animate-spin' : ''}" />
</Button>
{/snippet}
</WorkshopTooltip>
<WorkshopTooltip content="Close">
{#snippet trigger(props)}
<Button
{...props}
variant="ghost"
size="icon"
class="size-7 text-muted-foreground hover:text-white"
onclick={() => (showModerationPanel = false)}
aria-label="Close moderation panel"
>
<X class="size-4" />
</Button>
{/snippet}
</WorkshopTooltip>
</div>
</div>
<div class="min-h-0 flex-1 space-y-5 overflow-y-auto p-4">
{#if moderationError}
<div class="rounded-lg border border-red-900/40 bg-red-950/20 p-3 text-xs text-red-300">
{moderationError}
</div>
{/if}
<div class="rounded-lg border border-theme-800/60 bg-theme-900/20 p-2.5">
<div class="grid gap-2 sm:grid-cols-2">
<Select.Root
type="single"
value={moderationStatus}
onValueChange={(value) => {
if (!value) return;
moderationStatus = value as ThemeReportStatus | 'all';
handleModerationFilterChange();
}}
>
<Select.Trigger
class="h-8 border-theme-800 bg-theme-950/80 text-[11px] text-muted-foreground"
aria-label="Report status filter"
>
{REPORT_STATUS_OPTIONS.find((option) => option.id === moderationStatus)?.label ??
'Open reports'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
{#each REPORT_STATUS_OPTIONS as option (option.id)}
<Select.Item value={option.id} label={option.label} />
{/each}
</Select.Content>
</Select.Root>
<Select.Root
type="single"
value={moderationCategory}
onValueChange={(value) => {
if (!value) return;
moderationCategory = value as ThemeReportCategory | 'all';
handleModerationFilterChange();
}}
>
<Select.Trigger
class="h-8 border-theme-800 bg-theme-950/80 text-[11px] text-muted-foreground"
aria-label="Report category filter"
>
{REPORT_CATEGORIES.find((category) => category.id === moderationCategory)?.label ??
'All categories'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
<Select.Item value="all" label="All categories" />
{#each REPORT_CATEGORIES as category (category.id)}
<Select.Item value={category.id} label={category.label} />
{/each}
</Select.Content>
</Select.Root>
<Input
class="h-8 border-theme-800 bg-theme-950/80 text-[11px]"
placeholder="Filter by reason or theme..."
bind:value={moderationQuery}
onkeydown={(event) => {
if (event.key === 'Enter') handleModerationFilterChange();
}}
/>
<Input
class="h-8 border-theme-800 bg-theme-950/80 text-[11px]"
placeholder="Filter by theme author..."
bind:value={moderationAuthor}
onkeydown={(event) => {
if (event.key === 'Enter') handleModerationFilterChange();
}}
/>
<DatePicker
value={moderationFrom}
placeholder="From date"
ariaLabel="Reports from date"
contentClass="border-theme-800 bg-theme-950"
class="h-8 border-theme-800 bg-theme-950/80 text-[11px] text-muted-foreground"
onValueChange={(value) => {
if (moderationFrom === value) return;
moderationFrom = value;
handleModerationFilterChange();
}}
/>
<DatePicker
value={moderationTo}
placeholder="To date"
ariaLabel="Reports to date"
contentClass="border-theme-800 bg-theme-950"
class="h-8 border-theme-800 bg-theme-950/80 text-[11px] text-muted-foreground"
onValueChange={(value) => {
if (moderationTo === value) return;
moderationTo = value;
handleModerationFilterChange();
}}
/>
</div>
<div class="mt-2 flex flex-wrap items-center justify-between gap-2">
<Button
variant="ghost"
size="sm"
class="h-7 gap-1 text-[11px] text-muted-foreground hover:text-white"
onclick={handleModerationFilterChange}
>
<Search class="size-3" /> Apply filters
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 gap-1 text-[11px] text-muted-foreground hover:text-white"
onclick={() => void handleExportReports()}
disabled={exportingReports}
>
{#if exportingReports}
<LoaderCircle class="size-3 animate-spin" />
{:else}
<Download class="size-3" />
{/if}
Export CSV
</Button>
</div>
</div>
{#if selectedReportIds.length > 0}
<div
class="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 p-2.5"
>
<span class="text-[11px] font-medium text-amber-100">
{selectedReportIds.length} report(s) selected
</span>
<div class="flex items-center gap-1.5">
<Button
variant="ghost"
size="sm"
class="h-7 text-[11px] text-amber-100 hover:text-white"
onclick={() => void handleBulkReports('dismissed')}
disabled={bulkAction !== null}
>
{#if bulkAction === 'dismissed'}
<LoaderCircle class="mr-1 size-3 animate-spin" />
{/if}
Dismiss selected
</Button>
<Button
variant="secondary"
size="sm"
class="h-7 text-[11px]"
onclick={() => void handleBulkReports('hide')}
disabled={bulkAction !== null}
>
{#if bulkAction === 'hide'}
<LoaderCircle class="mr-1 size-3 animate-spin" />
{:else}
<EyeOff class="mr-1 size-3" />
{/if}
Hide themes
</Button>
</div>
</div>
{/if}
{#if moderationLoading && moderationReports.length === 0 && moderationEvents.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 moderation data...
</div>
{:else}
<section>
<h3
class="flex items-center gap-2 text-[11px] font-semibold tracking-wide text-white/80 uppercase"
>
<ShieldCheck class="size-3.5 text-cyan-300" />
Review queue
<Badge
variant="secondary"
class="bg-theme-800 px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground"
>
{reviewTotal}
</Badge>
</h3>
{#if reviewQueue.length === 0}
<p
class="mt-2 rounded-lg border border-theme-800/60 bg-theme-900/20 p-3 text-[11px] text-muted-foreground"
>
No themes are waiting for review.
</p>
{:else}
<div class="mt-2 space-y-2">
{#each reviewQueue as theme (theme.id)}
<div
class="flex items-start justify-between gap-3 rounded-lg border border-theme-800/70 bg-theme-900/30 p-3"
>
<div class="min-w-0">
<p class="truncate text-xs font-semibold text-white">{theme.name}</p>
<p class="mt-0.5 text-[10px] text-muted-foreground">
by {theme.author.name} · v{theme.version} ·
{formatModerationDate(theme.createdAt)}
</p>
{#if theme.description}
<p
class="mt-1 line-clamp-2 text-[10px] leading-relaxed text-muted-foreground"
>
{theme.description}
</p>
{/if}
</div>
<div class="flex shrink-0 items-center gap-1.5">
<Button
size="sm"
class="h-7 gap-1 text-xs"
onclick={() => void handleReviewTheme(theme, 'approved')}
disabled={reviewActionId === theme.id}
>
{#if reviewActionId === theme.id}
<LoaderCircle class="size-3 animate-spin" />
{:else}
<Check class="size-3" />
{/if}
Approve
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 gap-1 text-xs text-red-300 hover:bg-red-500/10 hover:text-red-200"
onclick={() => void handleReviewTheme(theme, 'rejected')}
disabled={reviewActionId === theme.id}
>
<X class="size-3" />
Reject
</Button>
</div>
</div>
{/each}
</div>
{/if}
</section>
<section>
<h3
class="flex items-center gap-2 text-[11px] font-semibold tracking-wide text-white/80 uppercase"
>
<Flag class="size-3.5 text-amber-300" />
Reports
<Badge
variant="secondary"
class="bg-theme-800 px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground"
>
{moderationTotal}
</Badge>
</h3>
{#if selectableReports.length > 0}
<Label
class="mt-2 flex w-fit cursor-pointer items-center gap-1.5 text-[10px] font-normal text-muted-foreground"
>
<Checkbox
checked={allSelectableReportsSelected}
onCheckedChange={() => toggleAllReports()}
/>
Select all visible open reports
</Label>
{/if}
{#if moderationReports.length === 0}
<p
class="mt-2 rounded-lg border border-theme-800/60 bg-theme-900/20 p-3 text-[11px] text-muted-foreground"
>
No reports match these filters.
</p>
{:else}
<div class="mt-2 space-y-2">
{#each moderationReports as report (report.id)}
<div class="rounded-lg border border-theme-800/70 bg-theme-900/30 p-3">
<div class="flex items-start gap-2">
{#if report.status === 'open' && !report.themeDeleted}
<Checkbox
class="mt-0.5 shrink-0"
checked={selectedReportIds.includes(report.id)}
onCheckedChange={() => toggleReportSelection(report.id)}
aria-label={`Select report for ${report.themeName}`}
/>
{/if}
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-1.5">
<p class="truncate text-xs font-semibold text-white">
{report.themeName}
</p>
<Badge
variant="outline"
class="border-theme-700/70 bg-theme-800/60 px-1.5 py-0.5 text-[9px] text-muted-foreground"
>
{reportCategoryLabel(report.category)}
</Badge>
{#if report.themeHidden}
<Badge variant="destructive" class="px-1.5 py-0 text-[9px]">
Hidden
</Badge>
{/if}
{#if report.themeQuarantined}
<Badge variant="destructive" class="px-1.5 py-0 text-[9px]">
Taken down
</Badge>
{/if}
{#if report.themeDeleted}
<Badge variant="destructive" class="px-1.5 py-0 text-[9px]">
Trash
</Badge>
{/if}
</div>
<p class="mt-0.5 text-[10px] text-muted-foreground">
by {report.themeAuthorName} · reported by {report.reporterName}
</p>
</div>
<span class="shrink-0 text-[10px] text-muted-foreground">
{formatModerationDate(report.createdAt)}
</span>
</div>
<p class="mt-2 text-[11px] leading-relaxed text-white/80">
{report.reason}
</p>
{#if report.status !== 'open'}
<p class="mt-2 text-[10px] text-muted-foreground">
{report.status === 'resolved' ? 'Resolved' : 'Dismissed'} by
{report.resolvedByName ?? 'Unknown'}
{#if report.resolvedAt}
· {formatModerationDate(report.resolvedAt)}
{/if}
</p>
{#if report.resolutionNote}
<p
class="mt-1 rounded border border-theme-800/60 bg-theme-950/40 px-2 py-1 text-[10px] text-muted-foreground"
>
Note: {report.resolutionNote}
</p>
{/if}
{/if}
<div class="mt-2 flex flex-wrap justify-end gap-2">
<Button
variant="ghost"
size="sm"
class="h-7 gap-1 text-xs text-muted-foreground hover:text-cyan-200"
onclick={() => void openThemeHistory(report.themeId, report.themeName)}
>
<History class="size-3" /> History
</Button>
{#if report.status === 'open'}
<Button
variant="ghost"
size="sm"
class="h-7 text-xs"
onclick={() => handleDismissReport(report)}
disabled={reportActionId !== null}
>
Dismiss
</Button>
<Button
variant="secondary"
size="sm"
class="h-7 gap-1 text-xs"
onclick={() => handleReportHide(report)}
disabled={reportActionId !== null || report.themeDeleted}
>
{#if reportActionId === report.id}
<LoaderCircle class="size-3 animate-spin" /> Working
{:else}
<EyeOff class="size-3" /> Hide theme
{/if}
</Button>
{/if}
</div>
</div>
</div>
</div>
{/each}
</div>
{#if moderationTotalPages > 1}
<div class="mt-3 flex items-center justify-between gap-2">
<Button
variant="ghost"
size="sm"
class="h-7 text-[11px]"
onclick={() => goToModerationPage(moderationPage - 1)}
disabled={moderationPage <= 1 || moderationLoading}
>
Previous
</Button>
<span class="text-[10px] text-muted-foreground">
Page {moderationPage} of {moderationTotalPages}
</span>
<Button
variant="ghost"
size="sm"
class="h-7 text-[11px]"
onclick={() => goToModerationPage(moderationPage + 1)}
disabled={moderationPage >= moderationTotalPages || moderationLoading}
>
Next
</Button>
</div>
{/if}
{/if}
</section>
<section>
<div class="flex flex-wrap items-center justify-between gap-2">
<h3
class="flex items-center gap-2 text-[11px] font-semibold tracking-wide text-white/80 uppercase"
>
<History class="size-3.5 text-cyan-300" />
Recent activity
</h3>
<Button
variant="ghost"
size="sm"
class="h-7 gap-1 text-[10px] text-muted-foreground hover:text-white"
onclick={() => void handleExportEvents()}
disabled={exportingEvents}
>
{#if exportingEvents}
<LoaderCircle class="size-3 animate-spin" />
{:else}
<Download class="size-3" />
{/if}
Export
</Button>
</div>
<div class="mt-2 flex flex-wrap gap-1.5">
<Select.Root
type="single"
value={moderationEventAction}
onValueChange={(value) => {
if (!value) return;
moderationEventAction = value;
handleAuditFilterChange();
}}
>
<Select.Trigger
class="h-7 w-auto min-w-36 border-theme-800 bg-theme-950/80 text-[10px] text-muted-foreground"
aria-label="Audit action filter"
>
{AUDIT_ACTIONS.find((action) => action.id === moderationEventAction)?.label ??
'All actions'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
{#each AUDIT_ACTIONS as action (action.id)}
<Select.Item value={action.id} label={action.label} />
{/each}
</Select.Content>
</Select.Root>
<Input
class="h-7 w-36 border-theme-800 bg-theme-950/80 text-[10px]"
placeholder="Filter by staff..."
bind:value={moderationEventActor}
onkeydown={(event) => {
if (event.key === 'Enter') handleAuditFilterChange();
}}
/>
<Button
variant="ghost"
size="sm"
class="h-7 text-[10px] text-muted-foreground hover:text-white"
onclick={handleAuditFilterChange}
>
Apply
</Button>
</div>
{#if moderationEvents.length === 0}
<p
class="mt-2 rounded-lg border border-theme-800/60 bg-theme-900/20 p-3 text-[11px] text-muted-foreground"
>
No moderation events yet.
</p>
{:else}
<div class="mt-2 space-y-1.5">
{#each moderationEvents as event (event.id)}
<div class="rounded-lg border border-theme-800/50 bg-theme-900/20 px-3 py-2">
<div class="flex items-center justify-between gap-2">
<p class="truncate text-[11px] text-white/80">
<span class="font-semibold text-white">{event.actorName}</span>
·
{moderationActionLabel(event.action)}
·
<span class="text-white/60">{event.themeName}</span>
</p>
<span class="shrink-0 text-[9px] text-muted-foreground">
{formatModerationDate(event.createdAt)}
</span>
</div>
{#if event.reason}
<WorkshopTooltip content={event.reason}>
{#snippet trigger(props)}
<p
{...props}
tabindex="-1"
class="mt-1 truncate text-[10px] text-muted-foreground"
>
{event.reason}
</p>
{/snippet}
</WorkshopTooltip>
{/if}
</div>
{/each}
</div>
{#if moderationEventsPage < moderationEventsTotalPages}
<div class="mt-2 flex justify-center">
<Button
variant="ghost"
size="sm"
class="h-7 text-[10px] text-muted-foreground hover:text-white"
onclick={() => void loadOlderEvents()}
disabled={moderationLoading}
>
Load older activity
</Button>
</div>
{/if}
{/if}
</section>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>
{#if announcementOpen}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
type="button"
class="absolute inset-0 cursor-default bg-black/75 backdrop-blur-sm"
aria-label="Close announcement composer"
onclick={() => {
if (!announcementSending) announcementOpen = false;
}}
></button>
<div
class="relative w-full max-w-lg overflow-hidden rounded-xl border border-theme-800 bg-theme-950 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="announcement-title"
>
<div class="flex items-start justify-between gap-3 border-b border-theme-800/70 p-4">
<div>
<h2
id="announcement-title"
class="flex items-center gap-2 text-sm font-semibold text-white"
>
<Megaphone class="size-4 text-cyan-300" /> Send announcement
</h2>
<p class="mt-1 text-[11px] text-muted-foreground">
This appears as a system notification in the recipient's hub.
</p>
</div>
<WorkshopTooltip content="Close">
{#snippet trigger(props)}
<Button
{...props}
variant="ghost"
size="icon"
class="size-7 text-muted-foreground hover:text-white"
onclick={() => {
if (!announcementSending) announcementOpen = false;
}}
>
<X class="size-4" />
</Button>
{/snippet}
</WorkshopTooltip>
</div>
<div class="space-y-3 p-4">
<label class="block space-y-1.5">
<span class="text-[10px] font-semibold tracking-wide text-white/70 uppercase"
>Title</span
>
<Input
class="h-8 border-theme-800 bg-theme-900/60 text-[11px]"
maxlength={120}
placeholder="Workshop maintenance tonight"
bind:value={announcementTitle}
/>
</label>
<label class="block space-y-1.5">
<span class="text-[10px] font-semibold tracking-wide text-white/70 uppercase"
>Message</span
>
<textarea
class="min-h-28 w-full resize-y rounded-md border border-theme-800 bg-theme-900/60 p-2.5 text-[11px] leading-relaxed text-white outline-none placeholder:text-muted-foreground/60 focus:border-primary/50"
rows="5"
maxlength="1000"
placeholder="Share an update with the community..."
bind:value={announcementMessage}
></textarea>
<span class="block text-right text-[9px] text-muted-foreground">
{announcementMessage.trim().length}/1000 · minimum 10
</span>
</label>
<div class="block space-y-1.5">
<span class="text-[10px] font-semibold tracking-wide text-white/70 uppercase"
>Audience</span
>
<Select.Root
type="single"
value={announcementAudience}
onValueChange={(value) => {
if (!value) return;
announcementAudience = value as 'all' | 'staff';
}}
>
<Select.Trigger
class="h-8 border-theme-800 bg-theme-900/60 text-[11px] text-white"
aria-label="Announcement audience"
>
{announcementAudience === 'staff' ? 'Staff only' : 'Everyone'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
<Select.Item value="all" label="Everyone" />
<Select.Item value="staff" label="Staff only" />
</Select.Content>
</Select.Root>
</div>
{#if announcementError}
<p
class="rounded-lg border border-red-900/40 bg-red-950/20 p-2.5 text-[11px] text-red-300"
>
{announcementError}
</p>
{/if}
</div>
<div class="flex items-center justify-end gap-2 border-t border-theme-800/70 p-3">
<Button
variant="ghost"
size="sm"
class="h-8 text-xs"
onclick={() => (announcementOpen = false)}
disabled={announcementSending}
>
Cancel
</Button>
<Button
size="sm"
class="h-8 gap-1 text-xs"
onclick={() => void submitAnnouncement()}
disabled={announcementSending}
>
{#if announcementSending}
<LoaderCircle class="size-3 animate-spin" /> Sending...
{:else}
<Megaphone class="size-3" /> Send announcement
{/if}
</Button>
</div>
</div>
</div>
{/if}
{#if sourceDialogOpen}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
type="button"
class="absolute inset-0 cursor-default bg-black/75 backdrop-blur-sm"
aria-label="Close fork origin"
onclick={() => (sourceDialogOpen = false)}
></button>
<div
class="relative w-full max-w-lg overflow-hidden rounded-xl border border-theme-800 bg-theme-950 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="fork-source-title"
>
<div class="flex items-start justify-between gap-3 border-b border-theme-800/70 p-4">
<div>
<h2
id="fork-source-title"
class="flex items-center gap-2 text-sm font-semibold text-white"
>
<GitFork class="size-4 text-cyan-300" /> Fork origin
</h2>
<p class="mt-1 text-[11px] text-muted-foreground">The immediate source of this fork.</p>
</div>
<WorkshopTooltip content="Close">
{#snippet trigger(props)}
<Button
{...props}
variant="ghost"
size="icon"
class="size-7 text-muted-foreground hover:text-white"
onclick={() => (sourceDialogOpen = false)}
>
<X class="size-4" />
</Button>
{/snippet}
</WorkshopTooltip>
</div>
<div class="p-4">
{#if sourceLoading}
<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 original...
</div>
{:else if sourceError}
<p
class="rounded-lg border border-amber-900/40 bg-amber-950/20 p-3 text-[11px] text-amber-200"
>
{sourceError}
</p>
{:else if sourceTheme}
<img
src={sourceTheme.previewUrl}
alt={sourceTheme.name}
class="aspect-video w-full rounded-lg border border-theme-800 object-cover"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).src = DefaultThemePreview;
}}
/>
<div class="mt-3 flex items-start justify-between gap-3">
<div class="min-w-0">
<h3 class="truncate text-sm font-semibold text-white">{sourceTheme.name}</h3>
<p class="mt-0.5 text-[11px] text-muted-foreground">
by {sourceTheme.author.name} · v{sourceTheme.version}
</p>
</div>
<Badge variant="outline" class="shrink-0 text-[10px]">
{sourceTheme.apiVersion === '1.0' ? 'Legacy v1' : 'API 2.x'}
</Badge>
</div>
{#if sourceTheme.description}
<p class="mt-3 text-[11px] leading-relaxed text-muted-foreground">
{sourceTheme.description}
</p>
{/if}
{#if sourceTheme.tags.length > 0}
<div class="mt-3 flex flex-wrap gap-1">
{#each sourceTheme.tags as tag (tag)}
<span
class="rounded bg-theme-800/50 px-1.5 py-0.5 text-[9px] text-muted-foreground"
>
#{tag}
</span>
{/each}
</div>
{/if}
<p class="mt-4 border-t border-theme-800/60 pt-3 text-[10px] text-muted-foreground">
This fork keeps a snapshot of the source at fork time. The original may have changed
since.
</p>
{/if}
</div>
</div>
</div>
{/if}
{#if tagEditorTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
type="button"
class="absolute inset-0 cursor-default bg-black/75 backdrop-blur-sm"
aria-label="Close tag editor"
onclick={closeTagEditor}
></button>
<div
class="relative w-full max-w-md overflow-hidden rounded-xl border border-theme-800 bg-theme-950 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="theme-tags-title"
>
<div class="flex items-start justify-between gap-3 border-b border-theme-800/70 p-4">
<div class="min-w-0">
<h2
id="theme-tags-title"
class="flex items-center gap-2 text-sm font-semibold text-white"
>
<Tag class="size-4 text-cyan-300" /> Edit theme tags
</h2>
<p class="mt-1 truncate text-[11px] text-muted-foreground">{tagEditorTarget.name}</p>
</div>
<Button
variant="ghost"
size="icon"
class="size-7 text-muted-foreground hover:text-white"
onclick={closeTagEditor}
disabled={tagSaving}
aria-label="Close tag editor"
>
<X class="size-4" />
</Button>
</div>
<div class="space-y-3 p-4">
<p class="text-[11px] leading-relaxed text-muted-foreground">
Tags are saved to the workshop and to the installed theme.json.
</p>
<div class="flex gap-2">
<Input
class="h-8 border-theme-800 bg-theme-900/60 text-[11px]"
bind:value={tagEditorInput}
placeholder="anime, dark, minimal"
maxlength={24}
disabled={tagSaving}
oninput={() => (tagEditorError = null)}
onkeydown={(event) => {
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault();
addTagEditorTag();
}
}}
/>
<Button
variant="secondary"
size="sm"
class="h-8 shrink-0 gap-1 text-xs"
onclick={addTagEditorTag}
disabled={tagSaving || !tagEditorInput.trim()}
>
<Plus class="size-3" /> Add
</Button>
</div>
{#if tagEditorTags.length > 0}
<div class="flex max-h-40 flex-wrap gap-1.5 overflow-y-auto">
{#each tagEditorTags as tag (tag)}
<span
class="inline-flex items-center gap-1 rounded-full border border-theme-700 bg-theme-900 px-2 py-1 text-[10px] text-theme-100"
>
#{tag}
<button
type="button"
class="rounded-full text-muted-foreground transition-colors hover:text-white disabled:opacity-50"
aria-label={`Remove ${tag} tag`}
onclick={() => removeTagEditorTag(tag)}
disabled={tagSaving}
>
<X class="size-3" />
</button>
</span>
{/each}
</div>
{:else}
<p
class="rounded-lg border border-theme-800/60 bg-theme-900/20 p-3 text-[11px] text-muted-foreground"
>
No tags yet. Add a few so people can find this theme.
</p>
{/if}
<div class="flex items-center justify-between gap-3 text-[9px] text-muted-foreground">
<span>Lowercase letters, numbers, dashes, underscores · max 24</span>
<span class="shrink-0 tabular-nums">{tagEditorTags.length}/10</span>
</div>
{#if tagEditorError}
<p
class="rounded-lg border border-red-900/40 bg-red-950/20 p-2.5 text-[11px] text-red-300"
>
{tagEditorError}
</p>
{/if}
</div>
<div class="flex items-center justify-end gap-2 border-t border-theme-800/70 p-3">
<Button
variant="ghost"
size="sm"
class="h-8 text-xs"
onclick={closeTagEditor}
disabled={tagSaving}
>
Cancel
</Button>
<Button
size="sm"
class="h-8 gap-1 text-xs"
onclick={() => void saveTagEditor()}
disabled={tagSaving}
>
{#if tagSaving}
<LoaderCircle class="size-3 animate-spin" /> Saving...
{:else}
<Tag class="size-3" /> Save tags
{/if}
</Button>
</div>
</div>
</div>
{/if}
{#if quarantineTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
type="button"
class="absolute inset-0 cursor-default bg-black/75 backdrop-blur-sm"
aria-label="Close takedown dialog"
onclick={closeQuarantineDialog}
></button>
<div
class="relative w-full max-w-md overflow-hidden rounded-xl border border-theme-800 bg-theme-950 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="quarantine-theme-title"
>
<div class="flex items-start justify-between gap-3 border-b border-theme-800/70 p-4">
<div>
<h2
id="quarantine-theme-title"
class="flex items-center gap-2 text-sm font-semibold text-white"
>
<Ban class="size-4 text-red-300" />
{quarantineAction === 'take_down' ? 'Take down theme' : 'Lift takedown'}
</h2>
<p class="mt-1 pr-2 text-[11px] leading-relaxed text-muted-foreground">
{#if quarantineAction === 'take_down'}
"{quarantineTarget.name}" will disappear from public search, downloads, previews and
updates until the takedown is lifted. The owner and open reporters are notified.
{:else}
"{quarantineTarget.name}" will become publicly visible again unless it is hidden or
still awaiting review.
{/if}
</p>
</div>
<Button
variant="ghost"
size="icon"
class="size-7 shrink-0 text-muted-foreground hover:text-white"
onclick={closeQuarantineDialog}
disabled={Boolean(quarantineId)}
aria-label="Close takedown dialog"
>
<X class="size-4" />
</Button>
</div>
<div class="space-y-3 p-4">
<label class="block space-y-1.5">
<span class="text-[10px] font-semibold tracking-wide text-white/70 uppercase"
>{quarantineAction === 'take_down' ? 'Reason' : 'Note (optional)'}</span
>
<textarea
class="min-h-24 w-full resize-y rounded-md border border-theme-800 bg-theme-900/60 p-2.5 text-[11px] leading-relaxed text-white outline-none placeholder:text-muted-foreground/60 focus:border-primary/50"
rows="4"
maxlength="500"
placeholder={quarantineAction === 'take_down'
? 'Explain why the theme is being taken down...'
: 'Optional note for the audit log...'}
bind:value={quarantineReason}
disabled={Boolean(quarantineId)}
oninput={() => (quarantineError = null)}
></textarea>
<span class="block text-right text-[9px] text-muted-foreground">
{quarantineReason.trim().length}/500
</span>
</label>
{#if quarantineError}
<p
class="rounded-lg border border-red-900/40 bg-red-950/20 p-2.5 text-[11px] text-red-300"
>
{quarantineError}
</p>
{/if}
</div>
<div class="flex items-center justify-end gap-2 border-t border-theme-800/70 p-3">
<Button
variant="ghost"
size="sm"
class="h-8 text-xs"
onclick={closeQuarantineDialog}
disabled={Boolean(quarantineId)}
>
Cancel
</Button>
<Button
variant={quarantineAction === 'take_down' ? 'destructive' : 'default'}
size="sm"
class="h-8 gap-1 text-xs"
onclick={() => void confirmQuarantine()}
disabled={Boolean(quarantineId)}
>
{#if quarantineId}
<LoaderCircle class="size-3 animate-spin" /> Updating...
{:else}
<Ban class="size-3" />
{quarantineAction === 'take_down' ? 'Take down theme' : 'Lift takedown'}
{/if}
</Button>
</div>
</div>
</div>
{/if}
{#if reportTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
type="button"
class="absolute inset-0 cursor-default bg-black/75 backdrop-blur-sm"
aria-label="Close report form"
onclick={() => {
if (!reportSubmitting) reportTarget = null;
}}
></button>
<div
class="relative w-full max-w-lg overflow-hidden rounded-xl border border-theme-800 bg-theme-950 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="report-theme-title"
>
<div class="flex items-start justify-between gap-3 border-b border-theme-800/70 p-4">
<div>
<h2
id="report-theme-title"
class="flex items-center gap-2 text-sm font-semibold text-white"
>
<Flag class="size-4 text-amber-300" /> Report theme
</h2>
<p class="mt-1 text-[11px] text-muted-foreground">
Reporting <span class="text-white/80">{reportTarget.name}</span> by
{reportTarget.author.name}. Reports are visible to moderators only.
</p>
</div>
<WorkshopTooltip content="Close">
{#snippet trigger(props)}
<Button
{...props}
variant="ghost"
size="icon"
class="size-7 text-muted-foreground hover:text-white"
onclick={() => {
if (!reportSubmitting) reportTarget = null;
}}
>
<X class="size-4" />
</Button>
{/snippet}
</WorkshopTooltip>
</div>
<div class="space-y-3 p-4">
<div class="block space-y-1.5">
<span class="text-[10px] font-semibold tracking-wide text-white/70 uppercase"
>Category</span
>
<Select.Root
type="single"
value={reportCategory}
onValueChange={(value) => {
if (!value) return;
reportCategory = value as ThemeReportCategory;
}}
>
<Select.Trigger
class="h-8 border-theme-800 bg-theme-900/60 text-[11px] text-white"
aria-label="Report category"
>
{REPORT_CATEGORIES.find((category) => category.id === reportCategory)?.label ??
'Other'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
{#each REPORT_CATEGORIES as category (category.id)}
<Select.Item value={category.id} label={category.label} />
{/each}
</Select.Content>
</Select.Root>
</div>
<label class="block space-y-1.5">
<span class="text-[10px] font-semibold tracking-wide text-white/70 uppercase"
>Details</span
>
<textarea
class="min-h-28 w-full resize-y rounded-md border border-theme-800 bg-theme-900/60 p-2.5 text-[11px] leading-relaxed text-white outline-none placeholder:text-muted-foreground/60 focus:border-primary/50"
rows="5"
maxlength="500"
placeholder="Describe what is wrong (at least 10 characters)..."
bind:value={reportDetails}
></textarea>
<span class="block text-right text-[9px] text-muted-foreground">
{reportDetails.trim().length}/500 · minimum 10
</span>
</label>
{#if reportError}
<p
class="rounded-lg border border-red-900/40 bg-red-950/20 p-2.5 text-[11px] text-red-300"
>
{reportError}
</p>
{/if}
</div>
<div class="flex items-center justify-end gap-2 border-t border-theme-800/70 p-3">
<Button
variant="ghost"
size="sm"
class="h-8 text-xs"
onclick={() => (reportTarget = null)}
disabled={reportSubmitting}
>
Cancel
</Button>
<Button
size="sm"
class="h-8 gap-1 text-xs"
onclick={() => void submitReport()}
disabled={reportSubmitting}
>
{#if reportSubmitting}
<LoaderCircle class="size-3 animate-spin" /> Sending...
{:else}
<Flag class="size-3" /> Submit report
{/if}
</Button>
</div>
</div>
</div>
{/if}
{#if historyOpen && historyTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
type="button"
class="absolute inset-0 cursor-default bg-black/75 backdrop-blur-sm"
aria-label="Close moderation history"
onclick={() => (historyOpen = false)}
></button>
<div
class="relative flex max-h-[80vh] w-full max-w-xl flex-col overflow-hidden rounded-xl border border-theme-800 bg-theme-950 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="theme-history-title"
>
<div class="flex items-start justify-between gap-3 border-b border-theme-800/70 p-4">
<div>
<h2
id="theme-history-title"
class="flex items-center gap-2 text-sm font-semibold text-white"
>
<History class="size-4 text-cyan-300" /> Moderation history
</h2>
<p class="mt-1 truncate text-[11px] text-muted-foreground">{historyTarget.name}</p>
</div>
<WorkshopTooltip content="Close">
{#snippet trigger(props)}
<Button
{...props}
variant="ghost"
size="icon"
class="size-7 text-muted-foreground hover:text-white"
onclick={() => (historyOpen = false)}
>
<X class="size-4" />
</Button>
{/snippet}
</WorkshopTooltip>
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-4">
{#if historyLoading}
<div class="flex h-32 items-center justify-center gap-2 text-xs text-muted-foreground">
<LoaderCircle class="size-4 animate-spin text-primary" /> Loading history...
</div>
{:else if historyError}
<p
class="rounded-lg border border-red-900/40 bg-red-950/20 p-3 text-[11px] text-red-300"
>
{historyError}
</p>
{:else if historyEvents.length === 0}
<p
class="rounded-lg border border-theme-800/60 bg-theme-900/20 p-3 text-[11px] text-muted-foreground"
>
No moderation events for this theme yet.
</p>
{:else}
<div class="space-y-1.5">
{#each historyEvents as event (event.id)}
<div class="rounded-lg border border-theme-800/50 bg-theme-900/20 px-3 py-2">
<div class="flex items-center justify-between gap-2">
<p class="truncate text-[11px] text-white/80">
<span class="font-semibold text-white">{event.actorName}</span>
·
{moderationActionLabel(event.action)}
</p>
<span class="shrink-0 text-[9px] text-muted-foreground">
{formatModerationDate(event.createdAt)}
</span>
</div>
{#if event.reason}
<p class="mt-1 text-[10px] text-muted-foreground">{event.reason}</p>
{/if}
</div>
{/each}
</div>
{#if historyPage < historyTotalPages}
<div class="mt-2 flex justify-center">
<Button
variant="ghost"
size="sm"
class="h-7 text-[10px] text-muted-foreground hover:text-white"
onclick={() => void loadOlderHistory()}
disabled={historyLoading}
>
Load older history
</Button>
</div>
{/if}
{/if}
</div>
</div>
</div>
{/if}
<AlertDialog.Root
open={restoreTarget !== null}
onOpenChange={(open) => {
if (!open) closeRestoreDialog();
}}
>
<AlertDialog.Content class="max-w-md border-theme-800 bg-theme-950 text-white">
<AlertDialog.Header>
<AlertDialog.Title class="flex items-center gap-2 text-base">
<RotateCcw class="size-4 text-cyan-300" /> Restore theme?
</AlertDialog.Title>
<AlertDialog.Description class="text-[11px] leading-relaxed text-muted-foreground">
Restore <span class="font-medium text-theme-100">{restoreTarget?.name}</span> to the workshop?
It will return with its existing visibility and moderation state.
</AlertDialog.Description>
</AlertDialog.Header>
{#if trashActionError}
<p class="rounded-md border border-red-900/50 bg-red-950/30 p-2 text-[11px] text-red-300">
{trashActionError}
</p>
{/if}
<AlertDialog.Footer class="gap-2">
<Button
variant="ghost"
size="sm"
onclick={closeRestoreDialog}
disabled={restoringId !== null}
>
Cancel
</Button>
<Button
size="sm"
class="gap-1.5"
onclick={() => void handleRestoreTheme()}
disabled={restoringId !== null}
>
{#if restoringId}
<LoaderCircle class="size-3.5 animate-spin" />
Restoring...
{:else}
<RotateCcw class="size-3.5" />
Restore theme
{/if}
</Button>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<AlertDialog.Root
open={purgeTarget !== null}
onOpenChange={(open) => {
if (!open) closePermanentDeleteDialog();
}}
>
<AlertDialog.Content class="max-w-md border-theme-800 bg-theme-950 text-white">
<AlertDialog.Header>
<AlertDialog.Title class="flex items-center gap-2 text-base">
<Trash2 class="size-4 text-red-400" /> Permanently delete theme?
</AlertDialog.Title>
<AlertDialog.Description class="text-[11px] leading-relaxed text-muted-foreground">
Permanently delete <span class="font-medium text-theme-100">{purgeTarget?.name}</span>?
This removes the theme package, preview, likes, downloads and reports from the server.
This cannot be undone. Installed copies on computers are not affected.
</AlertDialog.Description>
</AlertDialog.Header>
{#if trashActionError}
<p class="rounded-md border border-red-900/50 bg-red-950/30 p-2 text-[11px] text-red-300">
{trashActionError}
</p>
{/if}
<AlertDialog.Footer class="gap-2">
<Button
variant="ghost"
size="sm"
onclick={closePermanentDeleteDialog}
disabled={purgingId !== null}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
class="gap-1.5"
onclick={() => void handlePermanentDeleteTheme()}
disabled={purgingId !== null}
>
{#if purgingId}
<LoaderCircle class="size-3.5 animate-spin" />
Deleting...
{:else}
<Trash2 class="size-3.5" />
Delete permanently
{/if}
</Button>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
</Tooltip.Provider>