feat(themes): polish workshop moderation and theme controls

Add the in-app tag editor, installed-theme reporting, readable moderation and report dialogs, and notification layering fixes. Persist server-assigned workshop IDs for stable republishes, hide self-reporting, fix report-button contrast, and disable macOS autocorrect and spell checking across launcher windows.
This commit is contained in:
HorizonCode committed 2026-11-03 14:58:28 +01:00
1 parent 1f3fa026ce
commit ebc5fbf9c4
17 files changed
+964 -156

No files matched your search

+7 -1
View File
@@ -7,7 +7,13 @@
%sveltekit.head% %sveltekit.head%
</head> </head>
<body class="bg-theme-950" data-sveltekit-preload-data="hover"> <body
class="bg-theme-950"
data-sveltekit-preload-data="hover"
spellcheck="false"
autocorrect="off"
autocapitalize="none"
>
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>
</body> </body>
</html> </html>
+28 -5
View File
@@ -3,7 +3,12 @@ import { listen } from '@tauri-apps/api/event';
import * as path from '@tauri-apps/api/path'; import * as path from '@tauri-apps/api/path';
import * as fs from '@tauri-apps/plugin-fs'; import * as fs from '@tauri-apps/plugin-fs';
import { config } from './config'; import { config } from './config';
import { reloadThemes, themesDirectory } from './themes'; import {
reloadThemes,
themesDirectory,
updateInstalledThemeTags,
updateInstalledThemeWorkshopId,
} from './themes';
export interface CommunityThemeAuthor { export interface CommunityThemeAuthor {
id: number; id: number;
@@ -644,6 +649,8 @@ export async function setCommunityThemeVisibility(
export async function publishThemeToWorkshop(options: { export async function publishThemeToWorkshop(options: {
themeFolder: string; themeFolder: string;
themeId: string; themeId: string;
/** Existing server-assigned ID when updating an already-published theme. */
workshopId?: string;
name: string; name: string;
description?: string; description?: string;
version?: string; version?: string;
@@ -651,7 +658,7 @@ export async function publishThemeToWorkshop(options: {
tags?: string[]; tags?: string[];
previewImageBase64?: string; previewImageBase64?: string;
onProgress?: (status: string, percentage: number) => void; onProgress?: (status: string, percentage: number) => void;
}): Promise<{ success: boolean; theme: unknown }> { }): Promise<{ success: boolean; themeId: string; theme: unknown }> {
const { onProgress = () => {} } = options; const { onProgress = () => {} } = options;
const baseUrl = await getThemesServiceUrl(); const baseUrl = await getThemesServiceUrl();
const authHeaders = await getAuthHeaders(); const authHeaders = await getAuthHeaders();
@@ -699,7 +706,9 @@ export async function publishThemeToWorkshop(options: {
...authHeaders, ...authHeaders,
}, },
body: JSON.stringify({ body: JSON.stringify({
themeId: options.themeId, // The service uses this only when it belongs to an existing theme owned
// by the caller. Otherwise it assigns a fresh UUID and ignores it.
themeId: options.workshopId || options.themeId,
name: options.name, name: options.name,
description: options.description || '', description: options.description || '',
version: options.version || '1.0.0', version: options.version || '1.0.0',
@@ -768,10 +777,16 @@ export async function publishThemeToWorkshop(options: {
throw new Error(errData.error || `Finalizing upload failed (HTTP ${completeRes.status})`); throw new Error(errData.error || `Finalizing upload failed (HTTP ${completeRes.status})`);
} }
const completeData = await completeRes.json(); const completeData = (await completeRes.json()) as Record<string, unknown> & {
themeId?: unknown;
};
onProgress('Theme published successfully!', 100); onProgress('Theme published successfully!', 100);
return { success: true, theme: completeData }; return {
success: true,
themeId: typeof completeData.themeId === 'string' ? completeData.themeId : '',
theme: completeData,
};
} finally { } finally {
// Clean up temporary local zip // Clean up temporary local zip
if (await fs.exists(destinationZip)) { if (await fs.exists(destinationZip)) {
@@ -829,6 +844,14 @@ export async function downloadAndInstallCommunityTheme(
} }
} }
try {
// Keep the installed manifest aligned with the workshop record.
await updateInstalledThemeTags(folderName, theme.tags);
await updateInstalledThemeWorkshopId(folderName, theme.id);
} catch (err) {
console.warn('[Workshop] Could not write workshop metadata into the installed theme:', err);
}
reportProgress('Theme installed!', 100); reportProgress('Theme installed!', 100);
// Refresh themes in launcher state // Refresh themes in launcher state
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Bell, CheckCheck, LoaderCircle, Megaphone, RefreshCw, ShieldAlert, X } from '@lucide/svelte'; import { Bell, CheckCheck, LoaderCircle, Megaphone, RefreshCw, ShieldAlert, X } from '@lucide/svelte';
import { Portal } from 'bits-ui';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { currentUser } from '$lib/userAuthentication'; import { currentUser } from '$lib/userAuthentication';
import { import {
@@ -147,6 +148,7 @@
</button> </button>
{#if open} {#if open}
<Portal>
<button <button
type="button" type="button"
class="fixed inset-0 z-[80] cursor-default bg-black/30 backdrop-blur-[1px]" class="fixed inset-0 z-[80] cursor-default bg-black/30 backdrop-blur-[1px]"
@@ -300,4 +302,5 @@
{/if} {/if}
</div> </div>
</div> </div>
</Portal>
{/if} {/if}
@@ -30,12 +30,18 @@
import { reduceAnimations } from '$lib/userSettings'; import { reduceAnimations } from '$lib/userSettings';
import { currentUser } from '$lib/userAuthentication'; import { currentUser } from '$lib/userAuthentication';
import { isThemeTrusted } from '$lib/themeTrust'; import { isThemeTrusted } from '$lib/themeTrust';
import type { Theme } from '$lib/themes'; import { reloadThemes, updateInstalledThemeTags, type Theme } from '$lib/themes';
import { normalizeThemeTags } from '$lib/themeV2';
import Badge from '$lib/components/ui/badge/badge.svelte';
import { Button } from '$lib/components/ui/button'; 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 * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import Badge from '$lib/components/ui/badge/badge.svelte'; import { Label } from '$lib/components/ui/label';
import ScrollContainer from '$lib/components/ui/scroll-container/ScrollContainer.svelte'; import ScrollContainer from '$lib/components/ui/scroll-container/ScrollContainer.svelte';
import * as Select from '$lib/components/ui/select';
import { sileo } from 'sileo'; import { sileo } from 'sileo';
import { sendSystemAnnouncement } from '$lib/notifications'; import { sendSystemAnnouncement } from '$lib/notifications';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
@@ -61,6 +67,7 @@
LoaderCircle, LoaderCircle,
Megaphone, Megaphone,
Pencil, Pencil,
Plus,
RefreshCw, RefreshCw,
RotateCcw, RotateCcw,
Search, Search,
@@ -133,7 +140,12 @@
let exportingReports = $state(false); let exportingReports = $state(false);
let exportingEvents = $state(false); let exportingEvents = $state(false);
let quarantineId = $state<string | null>(null); let quarantineId = $state<string | null>(null);
let tagEditingId = $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 announcementOpen = $state(false);
let announcementTitle = $state(''); let announcementTitle = $state('');
@@ -162,6 +174,9 @@
let sourceError = $state<string | null>(null); let sourceError = $state<string | null>(null);
const TAGS = ['all', 'legacy', 'anime', 'minimal', 'video', 'audio', 'game', 'dark', 'clean']; const TAGS = ['all', 'legacy', 'anime', 'minimal', 'video', 'audio', 'game', 'dark', 'clean'];
const filterTags = $derived(
selectedTag !== 'all' && !TAGS.includes(selectedTag) ? [...TAGS, selectedTag] : TAGS
);
const REPORT_CATEGORIES: Array<{ id: ThemeReportCategory; label: string }> = [ const REPORT_CATEGORIES: Array<{ id: ThemeReportCategory; label: string }> = [
{ id: 'spam', label: 'Spam / advertising' }, { id: 'spam', label: 'Spam / advertising' },
{ id: 'harassment', label: 'Harassment / hate' }, { id: 'harassment', label: 'Harassment / hate' },
@@ -170,6 +185,12 @@
{ id: 'broken', label: 'Broken / unusable' }, { id: 'broken', label: 'Broken / unusable' },
{ id: 'other', label: 'Other' }, { id: 'other', label: 'Other' },
]; ];
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> = { const MODERATION_ACTIONS: Record<string, string> = {
hide: 'Hidden from the workshop', hide: 'Hidden from the workshop',
restore: 'Visibility restored', restore: 'Visibility restored',
@@ -524,37 +545,121 @@
} }
} }
async function handleEditTags(theme: CommunityTheme) { function openTagEditor(theme: CommunityTheme) {
if (tagEditingId || theme.deletedAt) return; if (theme.deletedAt) return;
const raw = window.prompt( tagEditorTarget = theme;
`Tags for "${theme.name}" (comma-separated, up to 10):`, tagEditorTags = normalizeThemeTags(theme.tags);
theme.tags.join(', ') tagEditorInput = '';
); tagEditorError = null;
if (raw === null) return; }
const tags = raw function addTagEditorTag() {
.split(',') const value = tagEditorInput.trim().toLowerCase();
.map((tag) => tag.trim()) if (!value) return;
.filter(Boolean); 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;
}
tagEditingId = theme.id; 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 { try {
const updated = await setCommunityThemeTags(theme.id, tags); const updated = await setCommunityThemeTags(target.id, tags);
themes = themes.map((item) => (item.id === updated.id ? updated : item)); themes = themes.map((item) => (item.id === updated.id ? updated : item));
sileo.success({
title: 'Theme tags updated', const installed =
description: updated.tags.length > 0 localThemeMap.get(target.id.toLowerCase()) ||
? `Tags: ${updated.tags.map((tag) => `#${tag}`).join(', ')}` localThemeMap.get(target.name.toLowerCase());
: 'All tags were removed.', 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(); await loadWorkshopThemes();
} catch (err) { } catch (err) {
sileo.error({ tagEditorError = err instanceof Error ? err.message : String(err);
title: 'Could not update tags',
description: err instanceof Error ? err.message : String(err),
});
} finally { } finally {
tagEditingId = null; tagSaving = false;
} }
} }
@@ -850,6 +955,8 @@
} }
function openAnnouncementComposer() { function openAnnouncementComposer() {
// The moderation panel is a modal Dialog; close it before opening another overlay.
showModerationPanel = false;
announcementOpen = true; announcementOpen = true;
announcementTitle = ''; announcementTitle = '';
announcementMessage = ''; announcementMessage = '';
@@ -1008,6 +1115,8 @@
} }
async function openThemeHistory(themeId: string, themeName: string) { async function openThemeHistory(themeId: string, themeName: string) {
// A custom modal cannot stack reliably over a portaled modal Dialog.
showModerationPanel = false;
historyOpen = true; historyOpen = true;
historyTarget = { id: themeId, name: themeName }; historyTarget = { id: themeId, name: themeName };
historyEvents = []; historyEvents = [];
@@ -1108,7 +1217,7 @@
Your trash Your trash
</span> </span>
{:else} {:else}
{#each TAGS as tag} {#each filterTags as tag (tag)}
<button <button
type="button" type="button"
class="rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors {selectedTag === tag class="rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors {selectedTag === tag
@@ -1260,11 +1369,10 @@
{@const owned = isOwnedByCurrentUser(theme)} {@const owned = isOwnedByCurrentUser(theme)}
{@const isLegacy = {@const isLegacy =
theme.apiVersion === '1.0' || Boolean(theme.tags?.includes('legacy'))} theme.apiVersion === '1.0' || Boolean(theme.tags?.includes('legacy'))}
{@const showOverflowMenu = {@const canReport =
!isDeleted && Boolean($currentUser) && !owned && !theme.isHidden && !theme.isQuarantined}
(owned || {@const canFork = canReport && !isLegacy}
canModerate || {@const showOverflowMenu = !isDeleted && (owned || canModerate || canReport)}
Boolean($currentUser && !isLegacy && !theme.isHidden && !theme.isQuarantined))}
<div <div
class="group flex flex-col overflow-hidden rounded-xl border bg-theme-900/30 {isActive class="group flex flex-col overflow-hidden rounded-xl border bg-theme-900/30 {isActive
@@ -1473,10 +1581,23 @@
{#if theme.tags && theme.tags.length > 0} {#if theme.tags && theme.tags.length > 0}
<div class="mt-2 flex flex-wrap gap-1"> <div class="mt-2 flex flex-wrap gap-1">
{#each theme.tags.slice(0, 3) as tag} {#each theme.tags.slice(0, 3) as tag (tag)}
<span class="rounded bg-theme-800/50 px-1.5 py-0.5 text-[9px] text-muted-foreground"> <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} #{tag}
</span> </button>
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -1689,16 +1810,10 @@
{#if owned} {#if owned}
<DropdownMenu.Item <DropdownMenu.Item
class="gap-2 text-[11px]" class="gap-2 text-[11px]"
onclick={() => void handleEditTags(theme)} onclick={() => openTagEditor(theme)}
disabled={tagEditingId === theme.id}
> >
{#if tagEditingId === theme.id} <Tag class="size-3.5" />
<LoaderCircle class="size-3.5 animate-spin" /> Edit tags
Saving tags...
{:else}
<Tag class="size-3.5" />
Edit tags
{/if}
</DropdownMenu.Item> </DropdownMenu.Item>
{#if !isLegacy && isInstalled && hasUpdate} {#if !isLegacy && isInstalled && hasUpdate}
<DropdownMenu.Item <DropdownMenu.Item
@@ -1709,25 +1824,29 @@
Refresh installed copy Refresh installed copy
</DropdownMenu.Item> </DropdownMenu.Item>
{/if} {/if}
{:else if $currentUser && !isLegacy && !theme.isHidden && !theme.isQuarantined} {:else if canReport || canFork}
<DropdownMenu.Item {#if canReport}
class="gap-2 text-[11px]" <DropdownMenu.Item
onclick={() => openReportDialog(theme)} class="gap-2 text-[11px]"
> onclick={() => openReportDialog(theme)}
<Flag class="size-3.5" /> >
Report theme <Flag class="size-3.5" />
</DropdownMenu.Item> Report theme
<DropdownMenu.Item </DropdownMenu.Item>
class="gap-2 text-[11px]" {/if}
onclick={() => handleForkTheme(theme)} {#if canFork}
> <DropdownMenu.Item
<CopyPlus class="size-3.5" /> class="gap-2 text-[11px]"
Fork theme onclick={() => handleForkTheme(theme)}
</DropdownMenu.Item> >
<CopyPlus class="size-3.5" />
Fork theme
</DropdownMenu.Item>
{/if}
{/if} {/if}
{#if canModerate} {#if canModerate}
{#if owned || ($currentUser && !isLegacy && !theme.isHidden && !theme.isQuarantined)} {#if owned || canReport}
<DropdownMenu.Separator class="my-1 bg-theme-800" /> <DropdownMenu.Separator class="my-1 bg-theme-800" />
{/if} {/if}
{#if !isLegacy} {#if !isLegacy}
@@ -1810,28 +1929,20 @@
</div> </div>
</div> </div>
{#if showModerationPanel} <Dialog.Root bind:open={showModerationPanel}>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4"> <Dialog.Content
<button 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"
type="button" >
class="absolute inset-0 cursor-default bg-black/75 backdrop-blur-sm" <div class="flex items-start justify-between gap-3 border-b border-theme-800/70 p-4">
aria-label="Close moderation panel" <div>
onclick={() => (showModerationPanel = false)} <Dialog.Title class="flex items-center gap-2 text-sm font-semibold text-white">
></button> <Flag class="size-4 text-amber-300" />
<div Moderation
class="relative flex max-h-[85vh] w-full max-w-3xl flex-col overflow-hidden rounded-xl border border-theme-800 bg-theme-950 shadow-2xl" </Dialog.Title>
role="dialog" <Dialog.Description class="mt-1 text-[11px] text-muted-foreground">
aria-modal="true" Open reports and recent staff activity.
aria-labelledby="moderation-title" </Dialog.Description>
> </div>
<div class="flex items-start justify-between gap-3 border-b border-theme-800/70 p-4">
<div>
<h2 id="moderation-title" class="flex items-center gap-2 text-sm font-semibold text-white">
<Flag class="size-4 text-amber-300" />
Moderation
</h2>
<p class="mt-1 text-[11px] text-muted-foreground">Open reports and recent staff activity.</p>
</div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<WorkshopTooltip content="Send a system announcement"> <WorkshopTooltip content="Send a system announcement">
{#snippet trigger(props)} {#snippet trigger(props)}
@@ -1841,6 +1952,7 @@
size="icon" size="icon"
class="size-7 text-muted-foreground hover:text-cyan-300" class="size-7 text-muted-foreground hover:text-cyan-300"
onclick={openAnnouncementComposer} onclick={openAnnouncementComposer}
aria-label="Send a system announcement"
> >
<Megaphone class="size-3.5" /> <Megaphone class="size-3.5" />
</Button> </Button>
@@ -1855,6 +1967,7 @@
class="size-7 text-muted-foreground hover:text-white" class="size-7 text-muted-foreground hover:text-white"
onclick={() => void loadModerationPanel()} onclick={() => void loadModerationPanel()}
disabled={moderationLoading} disabled={moderationLoading}
aria-label="Refresh moderation data"
> >
<RefreshCw class="size-3.5 {moderationLoading ? 'animate-spin' : ''}" /> <RefreshCw class="size-3.5 {moderationLoading ? 'animate-spin' : ''}" />
</Button> </Button>
@@ -1868,6 +1981,7 @@
size="icon" size="icon"
class="size-7 text-muted-foreground hover:text-white" class="size-7 text-muted-foreground hover:text-white"
onclick={() => (showModerationPanel = false)} onclick={() => (showModerationPanel = false)}
aria-label="Close moderation panel"
> >
<X class="size-4" /> <X class="size-4" />
</Button> </Button>
@@ -1885,28 +1999,51 @@
<div class="rounded-lg border border-theme-800/60 bg-theme-900/20 p-2.5"> <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"> <div class="grid gap-2 sm:grid-cols-2">
<select <Select.Root
class="h-8 rounded-md border border-theme-800 bg-theme-950/80 px-2 text-[11px] text-muted-foreground outline-none focus:border-primary/50" type="single"
bind:value={moderationStatus} value={moderationStatus}
onchange={handleModerationFilterChange} onValueChange={(value) => {
aria-label="Report status filter" if (!value) return;
moderationStatus = value as ThemeReportStatus | 'all';
handleModerationFilterChange();
}}
> >
<option value="open">Open reports</option> <Select.Trigger
<option value="resolved">Resolved reports</option> class="h-8 border-theme-800 bg-theme-950/80 text-[11px] text-muted-foreground"
<option value="dismissed">Dismissed reports</option> aria-label="Report status filter"
<option value="all">All reports</option> >
</select> {REPORT_STATUS_OPTIONS.find((option) => option.id === moderationStatus)?.label ??
<select 'Open reports'}
class="h-8 rounded-md border border-theme-800 bg-theme-950/80 px-2 text-[11px] text-muted-foreground outline-none focus:border-primary/50" </Select.Trigger>
bind:value={moderationCategory} <Select.Content class="border-theme-800 bg-theme-950">
onchange={handleModerationFilterChange} {#each REPORT_STATUS_OPTIONS as option (option.id)}
aria-label="Report category filter" <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();
}}
> >
<option value="all">All categories</option> <Select.Trigger
{#each REPORT_CATEGORIES as category} class="h-8 border-theme-800 bg-theme-950/80 text-[11px] text-muted-foreground"
<option value={category.id}>{category.label}</option> aria-label="Report category filter"
{/each} >
</select> {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 <Input
class="h-8 border-theme-800 bg-theme-950/80 text-[11px]" class="h-8 border-theme-800 bg-theme-950/80 text-[11px]"
placeholder="Filter by reason or theme..." placeholder="Filter by reason or theme..."
@@ -1923,19 +2060,29 @@
if (event.key === 'Enter') handleModerationFilterChange(); if (event.key === 'Enter') handleModerationFilterChange();
}} }}
/> />
<Input <DatePicker
type="date" value={moderationFrom}
class="h-8 border-theme-800 bg-theme-950/80 text-[11px]" placeholder="From date"
bind:value={moderationFrom} ariaLabel="Reports from date"
onchange={handleModerationFilterChange} contentClass="border-theme-800 bg-theme-950"
aria-label="Reports from date" class="h-8 border-theme-800 bg-theme-950/80 text-[11px] text-muted-foreground"
onValueChange={(value) => {
if (moderationFrom === value) return;
moderationFrom = value;
handleModerationFilterChange();
}}
/> />
<Input <DatePicker
type="date" value={moderationTo}
class="h-8 border-theme-800 bg-theme-950/80 text-[11px]" placeholder="To date"
bind:value={moderationTo} ariaLabel="Reports to date"
onchange={handleModerationFilterChange} contentClass="border-theme-800 bg-theme-950"
aria-label="Reports to date" 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>
<div class="mt-2 flex flex-wrap items-center justify-between gap-2"> <div class="mt-2 flex flex-wrap items-center justify-between gap-2">
@@ -2011,22 +2158,23 @@
<h3 class="flex items-center gap-2 text-[11px] font-semibold tracking-wide text-white/80 uppercase"> <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" /> <Flag class="size-3.5 text-amber-300" />
Reports Reports
<span class="rounded-full bg-theme-800 px-1.5 py-0.5 text-[10px] text-muted-foreground"> <Badge
variant="secondary"
class="bg-theme-800 px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground"
>
{moderationTotal} {moderationTotal}
</span> </Badge>
</h3> </h3>
{#if selectableReports.length > 0} {#if selectableReports.length > 0}
<label <Label
class="mt-2 flex w-fit cursor-pointer items-center gap-1.5 text-[10px] text-muted-foreground" class="mt-2 flex w-fit cursor-pointer items-center gap-1.5 text-[10px] font-normal text-muted-foreground"
> >
<input <Checkbox
type="checkbox"
class="size-3 accent-primary"
checked={allSelectableReportsSelected} checked={allSelectableReportsSelected}
onchange={toggleAllReports} onCheckedChange={() => toggleAllReports()}
/> />
Select all visible open reports Select all visible open reports
</label> </Label>
{/if} {/if}
{#if moderationReports.length === 0} {#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"> <p class="mt-2 rounded-lg border border-theme-800/60 bg-theme-900/20 p-3 text-[11px] text-muted-foreground">
@@ -2038,11 +2186,10 @@
<div class="rounded-lg border border-theme-800/70 bg-theme-900/30 p-3"> <div class="rounded-lg border border-theme-800/70 bg-theme-900/30 p-3">
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
{#if report.status === 'open' && !report.themeDeleted} {#if report.status === 'open' && !report.themeDeleted}
<input <Checkbox
type="checkbox" class="mt-0.5 shrink-0"
class="mt-0.5 size-3.5 shrink-0 accent-primary"
checked={selectedReportIds.includes(report.id)} checked={selectedReportIds.includes(report.id)}
onchange={() => toggleReportSelection(report.id)} onCheckedChange={() => toggleReportSelection(report.id)}
aria-label={`Select report for ${report.themeName}`} aria-label={`Select report for ${report.themeName}`}
/> />
{/if} {/if}
@@ -2051,19 +2198,26 @@
<div class="min-w-0"> <div class="min-w-0">
<div class="flex flex-wrap items-center gap-1.5"> <div class="flex flex-wrap items-center gap-1.5">
<p class="truncate text-xs font-semibold text-white">{report.themeName}</p> <p class="truncate text-xs font-semibold text-white">{report.themeName}</p>
<span <Badge
class="rounded-full border border-theme-700/70 bg-theme-800/60 px-1.5 py-0.5 text-[9px] text-muted-foreground" 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)} {reportCategoryLabel(report.category)}
</span> </Badge>
{#if report.themeHidden} {#if report.themeHidden}
<span class="text-[9px] text-red-300">Hidden</span> <Badge variant="destructive" class="px-1.5 py-0 text-[9px]">
Hidden
</Badge>
{/if} {/if}
{#if report.themeQuarantined} {#if report.themeQuarantined}
<span class="text-[9px] text-red-300">Taken down</span> <Badge variant="destructive" class="px-1.5 py-0 text-[9px]">
Taken down
</Badge>
{/if} {/if}
{#if report.themeDeleted} {#if report.themeDeleted}
<span class="text-[9px] text-red-300">Trash</span> <Badge variant="destructive" class="px-1.5 py-0 text-[9px]">
Trash
</Badge>
{/if} {/if}
</div> </div>
<p class="mt-0.5 text-[10px] text-muted-foreground"> <p class="mt-0.5 text-[10px] text-muted-foreground">
@@ -2178,16 +2332,28 @@
</Button> </Button>
</div> </div>
<div class="mt-2 flex flex-wrap gap-1.5"> <div class="mt-2 flex flex-wrap gap-1.5">
<select <Select.Root
class="h-7 rounded-md border border-theme-800 bg-theme-950/80 px-2 text-[10px] text-muted-foreground outline-none focus:border-primary/50" type="single"
bind:value={moderationEventAction} value={moderationEventAction}
onchange={handleAuditFilterChange} onValueChange={(value) => {
aria-label="Audit action filter" if (!value) return;
moderationEventAction = value;
handleAuditFilterChange();
}}
> >
{#each AUDIT_ACTIONS as action} <Select.Trigger
<option value={action.id}>{action.label}</option> class="h-7 w-auto min-w-36 border-theme-800 bg-theme-950/80 text-[10px] text-muted-foreground"
{/each} aria-label="Audit action filter"
</select> >
{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 <Input
class="h-7 w-36 border-theme-800 bg-theme-950/80 text-[10px]" class="h-7 w-36 border-theme-800 bg-theme-950/80 text-[10px]"
placeholder="Filter by staff..." placeholder="Filter by staff..."
@@ -2254,9 +2420,8 @@
</section> </section>
{/if} {/if}
</div> </div>
</div> </Dialog.Content>
</div> </Dialog.Root>
{/if}
{#if announcementOpen} {#if announcementOpen}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4"> <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
@@ -2452,6 +2617,140 @@
</div> </div>
{/if} {/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 reportTarget} {#if reportTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4"> <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<button <button
@@ -100,6 +100,7 @@
themeAssetBase, themeAssetBase,
themesDirectory, themesDirectory,
toSafeName, toSafeName,
updateInstalledThemeWorkshopId,
} from '$lib/themes'; } from '$lib/themes';
import { publishThemeToWorkshop } from '$lib/communityThemes'; import { publishThemeToWorkshop } from '$lib/communityThemes';
import type { EZPPUser, EZPPUserInfo } from '$lib/types'; import type { EZPPUser, EZPPUserInfo } from '$lib/types';
@@ -3364,7 +3365,7 @@
await saveTheme({ notify: false }); await saveTheme({ notify: false });
} }
publishThemeId = savedFolder || toSafeName(definition.name); publishThemeId = definition.workshopId ?? '';
publishProgress = 0; publishProgress = 0;
publishStatusMessage = ''; publishStatusMessage = '';
publishModalOpen = true; publishModalOpen = true;
@@ -3412,9 +3413,10 @@
const themeFolder = await path.join(baseThemesDir, savedFolder); const themeFolder = await path.join(baseThemesDir, savedFolder);
const themeId = savedFolder || toSafeName(definition.name); const themeId = savedFolder || toSafeName(definition.name);
await publishThemeToWorkshop({ const result = await publishThemeToWorkshop({
themeFolder, themeFolder,
themeId, themeId,
workshopId: definition.workshopId,
name: definition.name, name: definition.name,
description: definition.description || '', description: definition.description || '',
version: definition.version || '1.0.0', version: definition.version || '1.0.0',
@@ -3426,6 +3428,19 @@
}, },
}); });
if (result.themeId) {
// Persist the server-assigned ID so the next publish updates this
// workshop record instead of creating another one.
definition.workshopId = result.themeId;
publishThemeId = result.themeId;
try {
await updateInstalledThemeWorkshopId(savedFolder, result.themeId);
lastSavedKey = snapshotKey(captureSnapshot());
} catch (err) {
console.warn('[Workshop] Could not store the theme ID in theme.json:', err);
}
}
sileo.success({ sileo.success({
title: 'Published to Workshop!', title: 'Published to Workshop!',
description: `"${definition.name}" is now live in the Community Themes Workshop.`, description: `"${definition.name}" is now live in the Community Themes Workshop.`,
@@ -6904,7 +6919,11 @@
<span class="text-[10px] font-medium uppercase tracking-wide text-muted-foreground"> <span class="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Workshop ID Workshop ID
</span> </span>
<p class="mt-1 truncate font-mono text-[11px] text-theme-100">{publishThemeId}</p> {#if publishThemeId}
<p class="mt-1 truncate font-mono text-[11px] text-theme-100">{publishThemeId}</p>
{:else}
<p class="mt-1 text-[11px] text-muted-foreground">Assigned by EZPPFarm on publish</p>
{/if}
</div> </div>
<div> <div>
<span class="text-[10px] font-medium uppercase tracking-wide text-muted-foreground"> <span class="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
@@ -6959,7 +6978,7 @@
size="sm" size="sm"
class="bg-cyan-600 hover:bg-cyan-500 text-white font-medium gap-1.5" class="bg-cyan-600 hover:bg-cyan-500 text-white font-medium gap-1.5"
onclick={handlePublishToWorkshop} onclick={handlePublishToWorkshop}
disabled={publishing || publishThemeId.trim().length < 2 || definition.name.trim().length === 0} disabled={publishing || definition.name.trim().length === 0}
> >
{#if publishing} {#if publishing}
<LoaderCircle class="size-3.5 animate-spin" /> <LoaderCircle class="size-3.5 animate-spin" />
@@ -0,0 +1,175 @@
<script lang="ts">
import { Flag, LoaderCircle, X } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
import Textarea from '$lib/components/ui/textarea/textarea.svelte';
import {
reportCommunityTheme,
type CommunityTheme,
type ThemeReportCategory,
} from '$lib/communityThemes';
import { sileo } from 'sileo';
let {
open = $bindable(false),
theme,
}: {
open: boolean;
theme: CommunityTheme | null;
} = $props();
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' },
];
let category = $state<ThemeReportCategory>('other');
let details = $state('');
let error = $state<string | null>(null);
let submitting = $state(false);
$effect(() => {
if (!open || !theme) return;
category = 'other';
details = '';
error = null;
});
async function submitReport() {
const target = theme;
if (!target || submitting) return;
const reason = details.trim();
if (reason.length < 10) {
error = 'Please describe the problem in at least 10 characters.';
return;
}
submitting = true;
error = null;
try {
await reportCommunityTheme(target.id, reason.slice(0, 500), category);
sileo.success({
title: 'Report submitted',
description: 'Thank you. A moderator will review the theme.',
});
open = false;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
submitting = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content
class="flex w-[calc(100%_-_2rem)] max-w-lg flex-col gap-0 overflow-hidden rounded-xl border-theme-800 bg-theme-950 p-0 text-white shadow-2xl sm:rounded-xl"
>
{#if theme}
<div class="flex items-start justify-between gap-3 border-b border-theme-800/70 p-4">
<div class="min-w-0">
<Dialog.Title class="flex items-center gap-2 text-sm font-semibold text-white">
<Flag class="size-4 text-amber-300" /> Report theme
</Dialog.Title>
<Dialog.Description class="mt-1 text-[11px] text-muted-foreground">
Reporting <span class="text-white/80">{theme.name}</span> by {theme.author.name}. Reports
are visible to moderators only.
</Dialog.Description>
</div>
<Button
variant="ghost"
size="icon"
class="size-7 shrink-0 text-muted-foreground hover:text-white"
onclick={() => (open = false)}
disabled={submitting}
aria-label="Close report form"
>
<X class="size-4" />
</Button>
</div>
<div class="space-y-3 p-4">
<div class="space-y-1.5">
<Label for="installed-report-category" class="text-[10px] uppercase tracking-wide text-white/70">
Category
</Label>
<Select.Root
type="single"
value={category}
onValueChange={(value) => {
if (value) category = value as ThemeReportCategory;
}}
>
<Select.Trigger
id="installed-report-category"
class="h-8 border-theme-800 bg-theme-900/60 text-[11px] text-white"
>
{REPORT_CATEGORIES.find((item) => item.id === category)?.label ?? 'Other'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
{#each REPORT_CATEGORIES as item (item.id)}
<Select.Item value={item.id} label={item.label} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1.5">
<Label for="installed-report-details" class="text-[10px] uppercase tracking-wide text-white/70">
Details
</Label>
<Textarea
id="installed-report-details"
class="min-h-28 resize-y border-theme-800 bg-theme-900/60 text-[11px] leading-relaxed text-white"
rows={5}
maxlength={500}
placeholder="Describe what is wrong (at least 10 characters)..."
bind:value={details}
oninput={() => (error = null)}
disabled={submitting}
/>
<span class="block text-right text-[9px] text-muted-foreground">
{details.trim().length}/500 · minimum 10
</span>
</div>
{#if error}
<p class="rounded-lg border border-red-900/40 bg-red-950/20 p-2.5 text-[11px] text-red-300">
{error}
</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={() => (open = false)}
disabled={submitting}
>
Cancel
</Button>
<Button
size="sm"
class="h-8 gap-1 text-xs"
onclick={() => void submitReport()}
disabled={submitting}
>
{#if submitting}
<LoaderCircle class="size-3 animate-spin" /> Sending...
{:else}
<Flag class="size-3" /> Submit report
{/if}
</Button>
</div>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -18,6 +18,8 @@
contentClass = '', contentClass = '',
open = $bindable(false), open = $bindable(false),
id = undefined, id = undefined,
ariaLabel = undefined,
onValueChange = undefined,
}: { }: {
value?: string; value?: string;
withTime?: boolean; withTime?: boolean;
@@ -29,6 +31,8 @@
contentClass?: string; contentClass?: string;
open?: boolean; open?: boolean;
id?: string; id?: string;
ariaLabel?: string;
onValueChange?: (value: string) => void;
} = $props(); } = $props();
let calendarValue = $state<DateValue | undefined>(undefined); let calendarValue = $state<DateValue | undefined>(undefined);
@@ -70,6 +74,7 @@
if (!calendarValue) { if (!calendarValue) {
lastEmittedValue = ''; lastEmittedValue = '';
value = ''; value = '';
onValueChange?.('');
return; return;
} }
@@ -91,6 +96,7 @@
lastEmittedValue = next; lastEmittedValue = next;
value = next; value = next;
onValueChange?.(next);
} }
function handleDateChange(newDate: DateValue | undefined) { function handleDateChange(newDate: DateValue | undefined) {
@@ -147,6 +153,7 @@
<Popover.Trigger <Popover.Trigger
{id} {id}
{disabled} {disabled}
aria-label={ariaLabel}
class={cn( class={cn(
'flex h-8 w-full items-center justify-start gap-2 rounded-md border border-input bg-background px-3 text-xs font-normal text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background', 'flex h-8 w-full items-center justify-start gap-2 rounded-md border border-input bg-background px-3 text-xs font-normal text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background',
!formattedDisplay && 'text-muted-foreground', !formattedDisplay && 'text-muted-foreground',
@@ -0,0 +1,29 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import DialogOverlay from './dialog-overlay.svelte';
import { Dialog as DialogPrimitive, type WithoutChild } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
portalProps,
children,
...restProps
}: WithoutChild<DialogPrimitive.ContentProps> & {
portalProps?: DialogPrimitive.PortalProps;
} = $props();
</script>
<DialogPrimitive.Portal {...portalProps}>
<DialogOverlay />
<DialogPrimitive.Content
bind:ref
class={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
className
)}
{...restProps}
>
{@render children?.()}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
@@ -0,0 +1,16 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Dialog as DialogPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.DescriptionProps = $props();
</script>
<DialogPrimitive.Description
bind:ref
class={cn('text-muted-foreground text-sm', className)}
{...restProps}
/>
@@ -0,0 +1,19 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Dialog as DialogPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
class={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80 backdrop-blur-sm',
className
)}
{...restProps}
/>
@@ -0,0 +1,18 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Dialog as DialogPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
level = 2,
...restProps
}: DialogPrimitive.TitleProps = $props();
</script>
<DialogPrimitive.Title
bind:ref
class={cn('text-lg font-semibold', className)}
{level}
{...restProps}
/>
@@ -0,0 +1,10 @@
<script lang="ts">
import { Dialog as DialogPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
let {
open = $bindable(false),
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.RootProps> = $props();
</script>
<DialogPrimitive.Root bind:open {...restProps} />
+24
View File
@@ -0,0 +1,24 @@
import Content from './dialog-content.svelte';
import Description from './dialog-description.svelte';
import Overlay from './dialog-overlay.svelte';
import Title from './dialog-title.svelte';
import { Dialog as DialogPrimitive } from 'bits-ui';
const Root = DialogPrimitive.Root;
const Trigger = DialogPrimitive.Trigger;
export {
Root,
Trigger,
Overlay,
Content,
Title,
Description,
//
Root as Dialog,
Trigger as DialogTrigger,
Overlay as DialogOverlay,
Content as DialogContent,
Title as DialogTitle,
Description as DialogDescription,
};
+12
View File
@@ -649,6 +649,8 @@ export type ThemeV2Definition = {
description?: string; description?: string;
/** Workshop discovery tags for this theme. */ /** Workshop discovery tags for this theme. */
tags?: string[]; tags?: string[];
/** Server-assigned workshop ID, stored so republishing updates the same theme. */
workshopId?: string;
/** Marks the theme as adult content in the theme manager. */ /** Marks the theme as adult content in the theme manager. */
nsfw?: boolean; nsfw?: boolean;
/** Optional preview image, relative to the theme's assets folder. */ /** Optional preview image, relative to the theme's assets folder. */
@@ -742,6 +744,15 @@ export const normalizeThemeTags = (raw: unknown): string[] => {
return tags; return tags;
}; };
const WORKSHOP_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
/** Keep a server-assigned workshop ID while preserving legacy slug IDs. */
const normalizeWorkshopId = (raw: unknown): string | undefined => {
if (typeof raw !== 'string') return undefined;
const id = raw.trim().toLowerCase();
return WORKSHOP_ID_PATTERN.test(id) ? id : undefined;
};
const clampInteger = (value: unknown, fallback: number, min: number, max: number) => const clampInteger = (value: unknown, fallback: number, min: number, max: number) =>
Math.round(clamp(value, fallback, min, max)); Math.round(clamp(value, fallback, min, max));
@@ -1901,6 +1912,7 @@ export const normalizeThemeV2 = (raw: Partial<ThemeV2Definition>): ThemeV2Defini
author: text(raw.author, 'Unknown'), author: text(raw.author, 'Unknown'),
description: text(raw.description), description: text(raw.description),
tags: normalizeThemeTags(raw.tags), tags: normalizeThemeTags(raw.tags),
workshopId: normalizeWorkshopId(raw.workshopId),
nsfw: raw.nsfw === true, nsfw: raw.nsfw === true,
preview: text(raw.preview), preview: text(raw.preview),
background, background,
+41
View File
@@ -10,6 +10,8 @@ import * as fs from '@tauri-apps/plugin-fs';
export type Theme = { export type Theme = {
folder_name: string; folder_name: string;
/** Server-assigned workshop ID, when this theme has been published. */
workshopId?: string;
name: string; name: string;
version: string; version: string;
apiVersion: string; apiVersion: string;
@@ -40,6 +42,7 @@ export type ThemeInfo = {
name: string; name: string;
version?: string; version?: string;
apiVersion?: string; apiVersion?: string;
workshopId?: string;
author?: string; author?: string;
description?: string; description?: string;
nsfw?: boolean; nsfw?: boolean;
@@ -165,6 +168,7 @@ const themeFromFolder = async (
return { return {
folder_name: folderName, folder_name: folderName,
workshopId: typeof parsed.workshopId === 'string' ? parsed.workshopId : undefined,
name: parsed.name, name: parsed.name,
version: parsed.version ?? '1.0.0', version: parsed.version ?? '1.0.0',
apiVersion, apiVersion,
@@ -559,6 +563,43 @@ export const saveThemeProject = async (
return themeFolder; return themeFolder;
}; };
const updateInstalledThemeManifest = async (
folderName: string,
patch: Record<string, unknown>
) => {
const themeFolder = await path.join(await themesDirectory(), folderName);
const configPath = await path.join(themeFolder, 'theme.json');
if (!(await fs.exists(configPath))) {
throw new Error('The installed theme.json file was not found.');
}
let parsed: unknown;
try {
parsed = JSON.parse(await fs.readTextFile(configPath));
} catch {
throw new Error('The installed theme.json file is not valid JSON.');
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('The installed theme.json file is not a valid theme manifest.');
}
const updated = { ...(parsed as Record<string, unknown>), ...patch };
await fs.writeFile(
configPath,
new TextEncoder().encode(`${JSON.stringify(updated, null, 2)}\n`)
);
};
/** Write workshop tags into an installed theme's manifest without dropping other fields. */
export const updateInstalledThemeTags = async (folderName: string, tags: string[]) =>
updateInstalledThemeManifest(folderName, { tags });
/** Remember the server-assigned workshop ID so later publishes update the same theme. */
export const updateInstalledThemeWorkshopId = async (
folderName: string,
workshopId: string
) => updateInstalledThemeManifest(folderName, { workshopId });
export const exportThemeFolder = async (folderName: string, destinationPath: string) => { export const exportThemeFolder = async (folderName: string, destinationPath: string) => {
const themeFolder = await path.join(await themesDirectory(), folderName); const themeFolder = await path.join(await themesDirectory(), folderName);
await invoke('export_theme', { themeFolder, destinationPath }); await invoke('export_theme', { themeFolder, destinationPath });
+45
View File
@@ -81,6 +81,51 @@
unlistenPresenceEditorState?.(); unlistenPresenceEditorState?.();
}); });
// macOS WKWebView enables autocorrect, capitalization and spell checking
// for editable fields by default. Disable them across the launcher so
// usernames, search fields and theme text are not rewritten while typing.
const disableTextAssistance = (element: Element) => {
if (
!(element instanceof HTMLInputElement) &&
!(element instanceof HTMLTextAreaElement) &&
!(element instanceof HTMLElement && element.isContentEditable)
) {
return;
}
element.spellcheck = false;
element.setAttribute('autocorrect', 'off');
element.setAttribute('autocapitalize', 'none');
};
const disableTextAssistanceIn = (root: ParentNode) => {
if (root instanceof Element) disableTextAssistance(root);
root.querySelectorAll('input, textarea, [contenteditable]').forEach(disableTextAssistance);
};
onMount(() => {
disableTextAssistanceIn(document);
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
mutation.addedNodes.forEach((node) => {
if (node instanceof Element) disableTextAssistanceIn(node);
});
}
});
observer.observe(document.body, { childList: true, subtree: true });
const handleFocus = (event: FocusEvent) => {
if (event.target instanceof Element) disableTextAssistance(event.target);
};
document.addEventListener('focusin', handleFocus, true);
return () => {
observer.disconnect();
document.removeEventListener('focusin', handleFocus, true);
};
});
if (browser) initLogging(); if (browser) initLogging();
const editorWindow = (() => { const editorWindow = (() => {
+62
View File
@@ -2,11 +2,16 @@
import DefaultThemePreview from '$assets/default_preview.png'; import DefaultThemePreview from '$assets/default_preview.png';
import Logo from '$assets/logo.png'; import Logo from '$assets/logo.png';
import { ezppfarm } from '$lib/api/ezpp'; import { ezppfarm } from '$lib/api/ezpp';
import {
fetchCommunityThemeDetails,
type CommunityTheme,
} from '$lib/communityThemes';
import DiagnosticsPanel from '$lib/components/diagnostics/DiagnosticsPanel.svelte'; import DiagnosticsPanel from '$lib/components/diagnostics/DiagnosticsPanel.svelte';
import LogViewer from '$lib/components/logs/LogViewer.svelte'; import LogViewer from '$lib/components/logs/LogViewer.svelte';
import NotificationHub from '$lib/components/notifications/NotificationHub.svelte'; import NotificationHub from '$lib/components/notifications/NotificationHub.svelte';
import DiscordPresenceEditor from '$lib/components/presence/DiscordPresenceEditor.svelte'; import DiscordPresenceEditor from '$lib/components/presence/DiscordPresenceEditor.svelte';
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte'; import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
import ThemeReportDialog from '$lib/components/theme/ThemeReportDialog.svelte';
import ThemeV2Elements from '$lib/components/theme/ThemeV2Elements.svelte'; import ThemeV2Elements from '$lib/components/theme/ThemeV2Elements.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog'; import * as AlertDialog from '$lib/components/ui/alert-dialog';
import * as Avatar from '$lib/components/ui/avatar'; import * as Avatar from '$lib/components/ui/avatar';
@@ -158,6 +163,7 @@
ChevronRight, ChevronRight,
Clock, Clock,
ExternalLink, ExternalLink,
Flag,
Globe, Globe,
Medal, Medal,
TrendingDown, TrendingDown,
@@ -302,6 +308,9 @@
}; };
let restoreCandidate = $state<{ theme: Theme; backup: ThemeBackup } | undefined>(undefined); let restoreCandidate = $state<{ theme: Theme; backup: ThemeBackup } | undefined>(undefined);
let restoringTheme = $state(false); let restoringTheme = $state(false);
let installedReportOpen = $state(false);
let installedReportTarget = $state<CommunityTheme | null>(null);
let installedReportLoading = $state(false);
let logViewerOpen = $state(false); let logViewerOpen = $state(false);
let presenceEditorOpen = $state(false); let presenceEditorOpen = $state(false);
@@ -339,6 +348,12 @@
}); });
}); });
const isOwnInstalledTheme = (theme: Theme) =>
Boolean(
$currentUser &&
theme.author.trim().toLowerCase() === $currentUser.name.trim().toLowerCase()
);
const themeButtonLabel = (theme: Theme, activeFolder: string | undefined) => { const themeButtonLabel = (theme: Theme, activeFolder: string | undefined) => {
if (activeFolder === theme.folder_name) return 'Theme in use'; if (activeFolder === theme.folder_name) return 'Theme in use';
if (theme.status === 'downloading') { if (theme.status === 'downloading') {
@@ -432,6 +447,38 @@
}); });
}; };
const openInstalledReport = async (theme: Theme) => {
if (!$currentUser) {
sileo.error({
title: 'Login required',
description: 'Log in to EZPPFarm to report a community theme.',
});
return;
}
if (installedReportLoading) return;
installedReportLoading = true;
try {
const target = await fetchCommunityThemeDetails(theme.folder_name);
if (target.isOwner) {
sileo.error({
title: 'You cannot report your own theme',
description: 'Reporting is available for themes created by other users.',
});
return;
}
installedReportTarget = target;
installedReportOpen = true;
} catch (err) {
sileo.error({
title: 'Theme not available in the workshop',
description: err instanceof Error ? err.message : String(err),
});
} finally {
installedReportLoading = false;
}
};
const requestThemeApply = async (theme: Theme) => { const requestThemeApply = async (theme: Theme) => {
if (theme.status !== 'installed') { if (theme.status !== 'installed') {
const downloadThemeResult = await downloadTheme(theme); const downloadThemeResult = await downloadTheme(theme);
@@ -1972,6 +2019,8 @@
</AlertDialog.Content> </AlertDialog.Content>
</AlertDialog.Root> </AlertDialog.Root>
<ThemeReportDialog bind:open={installedReportOpen} theme={installedReportTarget} />
<div class="grid grid-cols-[0.085fr_1fr] h-screen relative" bind:this={dragAndDrop.ref}> <div class="grid grid-cols-[0.085fr_1fr] h-screen relative" bind:this={dragAndDrop.ref}>
{#if dragAndDrop.isDraggingOverApp && !droppedTheme} {#if dragAndDrop.isDraggingOverApp && !droppedTheme}
<div <div
@@ -3993,6 +4042,19 @@
{/if} {/if}
</Button> </Button>
{/if} {/if}
{#if $currentUser && !isOwnInstalledTheme(theme) && theme.status === 'installed' && theme.name !== 'Default' && theme.folder_name !== 'default'}
<Button
variant="secondary"
size="icon"
class="size-7 shrink-0 text-muted-foreground hover:bg-amber-500/15 hover:text-amber-300"
title="Report theme"
aria-label={`Report ${theme.name}`}
disabled={installedReportLoading}
onclick={() => void openInstalledReport(theme)}
>
<Flag class="size-3.5" />
</Button>
{/if}
</div> </div>
</div> </div>
</div> </div>