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:
1 parent
1f3fa026ce
commit
ebc5fbf9c4
17 files changed
+933
-125
No files matched your search
+7
-1
@@ -7,7 +7,13 @@
|
||||
%sveltekit.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>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,7 +3,12 @@ import { listen } from '@tauri-apps/api/event';
|
||||
import * as path from '@tauri-apps/api/path';
|
||||
import * as fs from '@tauri-apps/plugin-fs';
|
||||
import { config } from './config';
|
||||
import { reloadThemes, themesDirectory } from './themes';
|
||||
import {
|
||||
reloadThemes,
|
||||
themesDirectory,
|
||||
updateInstalledThemeTags,
|
||||
updateInstalledThemeWorkshopId,
|
||||
} from './themes';
|
||||
|
||||
export interface CommunityThemeAuthor {
|
||||
id: number;
|
||||
@@ -644,6 +649,8 @@ export async function setCommunityThemeVisibility(
|
||||
export async function publishThemeToWorkshop(options: {
|
||||
themeFolder: string;
|
||||
themeId: string;
|
||||
/** Existing server-assigned ID when updating an already-published theme. */
|
||||
workshopId?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
@@ -651,7 +658,7 @@ export async function publishThemeToWorkshop(options: {
|
||||
tags?: string[];
|
||||
previewImageBase64?: string;
|
||||
onProgress?: (status: string, percentage: number) => void;
|
||||
}): Promise<{ success: boolean; theme: unknown }> {
|
||||
}): Promise<{ success: boolean; themeId: string; theme: unknown }> {
|
||||
const { onProgress = () => {} } = options;
|
||||
const baseUrl = await getThemesServiceUrl();
|
||||
const authHeaders = await getAuthHeaders();
|
||||
@@ -699,7 +706,9 @@ export async function publishThemeToWorkshop(options: {
|
||||
...authHeaders,
|
||||
},
|
||||
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,
|
||||
description: options.description || '',
|
||||
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})`);
|
||||
}
|
||||
|
||||
const completeData = await completeRes.json();
|
||||
const completeData = (await completeRes.json()) as Record<string, unknown> & {
|
||||
themeId?: unknown;
|
||||
};
|
||||
onProgress('Theme published successfully!', 100);
|
||||
|
||||
return { success: true, theme: completeData };
|
||||
return {
|
||||
success: true,
|
||||
themeId: typeof completeData.themeId === 'string' ? completeData.themeId : '',
|
||||
theme: completeData,
|
||||
};
|
||||
} finally {
|
||||
// Clean up temporary local zip
|
||||
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);
|
||||
|
||||
// Refresh themes in launcher state
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Bell, CheckCheck, LoaderCircle, Megaphone, RefreshCw, ShieldAlert, X } from '@lucide/svelte';
|
||||
import { Portal } from 'bits-ui';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { currentUser } from '$lib/userAuthentication';
|
||||
import {
|
||||
@@ -147,6 +148,7 @@
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<Portal>
|
||||
<button
|
||||
type="button"
|
||||
class="fixed inset-0 z-[80] cursor-default bg-black/30 backdrop-blur-[1px]"
|
||||
@@ -300,4 +302,5 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
{/if}
|
||||
@@ -30,12 +30,18 @@
|
||||
import { reduceAnimations } from '$lib/userSettings';
|
||||
import { currentUser } from '$lib/userAuthentication';
|
||||
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 { 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 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 * as Select from '$lib/components/ui/select';
|
||||
import { sileo } from 'sileo';
|
||||
import { sendSystemAnnouncement } from '$lib/notifications';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
@@ -61,6 +67,7 @@
|
||||
LoaderCircle,
|
||||
Megaphone,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Search,
|
||||
@@ -133,7 +140,12 @@
|
||||
let exportingReports = $state(false);
|
||||
let exportingEvents = $state(false);
|
||||
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 announcementTitle = $state('');
|
||||
@@ -162,6 +174,9 @@
|
||||
let sourceError = $state<string | null>(null);
|
||||
|
||||
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 }> = [
|
||||
{ id: 'spam', label: 'Spam / advertising' },
|
||||
{ id: 'harassment', label: 'Harassment / hate' },
|
||||
@@ -170,6 +185,12 @@
|
||||
{ id: 'broken', label: 'Broken / unusable' },
|
||||
{ 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> = {
|
||||
hide: 'Hidden from the workshop',
|
||||
restore: 'Visibility restored',
|
||||
@@ -524,37 +545,121 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditTags(theme: CommunityTheme) {
|
||||
if (tagEditingId || theme.deletedAt) return;
|
||||
const raw = window.prompt(
|
||||
`Tags for "${theme.name}" (comma-separated, up to 10):`,
|
||||
theme.tags.join(', ')
|
||||
);
|
||||
if (raw === null) return;
|
||||
function openTagEditor(theme: CommunityTheme) {
|
||||
if (theme.deletedAt) return;
|
||||
tagEditorTarget = theme;
|
||||
tagEditorTags = normalizeThemeTags(theme.tags);
|
||||
tagEditorInput = '';
|
||||
tagEditorError = null;
|
||||
}
|
||||
|
||||
const tags = raw
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
const updated = await setCommunityThemeTags(theme.id, tags);
|
||||
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: updated.tags.length > 0
|
||||
? `Tags: ${updated.tags.map((tag) => `#${tag}`).join(', ')}`
|
||||
: 'All tags were removed.',
|
||||
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();
|
||||
} catch (err) {
|
||||
sileo.error({
|
||||
title: 'Could not update tags',
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
tagEditorError = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
tagEditingId = null;
|
||||
tagSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -850,6 +955,8 @@
|
||||
}
|
||||
|
||||
function openAnnouncementComposer() {
|
||||
// The moderation panel is a modal Dialog; close it before opening another overlay.
|
||||
showModerationPanel = false;
|
||||
announcementOpen = true;
|
||||
announcementTitle = '';
|
||||
announcementMessage = '';
|
||||
@@ -1008,6 +1115,8 @@
|
||||
}
|
||||
|
||||
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 = [];
|
||||
@@ -1108,7 +1217,7 @@
|
||||
Your trash
|
||||
</span>
|
||||
{:else}
|
||||
{#each TAGS as tag}
|
||||
{#each filterTags as tag (tag)}
|
||||
<button
|
||||
type="button"
|
||||
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 isLegacy =
|
||||
theme.apiVersion === '1.0' || Boolean(theme.tags?.includes('legacy'))}
|
||||
{@const showOverflowMenu =
|
||||
!isDeleted &&
|
||||
(owned ||
|
||||
canModerate ||
|
||||
Boolean($currentUser && !isLegacy && !theme.isHidden && !theme.isQuarantined))}
|
||||
{@const canReport =
|
||||
Boolean($currentUser) && !owned && !theme.isHidden && !theme.isQuarantined}
|
||||
{@const canFork = canReport && !isLegacy}
|
||||
{@const showOverflowMenu = !isDeleted && (owned || canModerate || canReport)}
|
||||
|
||||
<div
|
||||
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}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{#each theme.tags.slice(0, 3) as tag}
|
||||
<span class="rounded bg-theme-800/50 px-1.5 py-0.5 text-[9px] text-muted-foreground">
|
||||
{#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}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1689,16 +1810,10 @@
|
||||
{#if owned}
|
||||
<DropdownMenu.Item
|
||||
class="gap-2 text-[11px]"
|
||||
onclick={() => void handleEditTags(theme)}
|
||||
disabled={tagEditingId === theme.id}
|
||||
onclick={() => openTagEditor(theme)}
|
||||
>
|
||||
{#if tagEditingId === theme.id}
|
||||
<LoaderCircle class="size-3.5 animate-spin" />
|
||||
Saving tags...
|
||||
{:else}
|
||||
<Tag class="size-3.5" />
|
||||
Edit tags
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{#if !isLegacy && isInstalled && hasUpdate}
|
||||
<DropdownMenu.Item
|
||||
@@ -1709,7 +1824,8 @@
|
||||
Refresh installed copy
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{:else if $currentUser && !isLegacy && !theme.isHidden && !theme.isQuarantined}
|
||||
{:else if canReport || canFork}
|
||||
{#if canReport}
|
||||
<DropdownMenu.Item
|
||||
class="gap-2 text-[11px]"
|
||||
onclick={() => openReportDialog(theme)}
|
||||
@@ -1717,6 +1833,8 @@
|
||||
<Flag class="size-3.5" />
|
||||
Report theme
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if canFork}
|
||||
<DropdownMenu.Item
|
||||
class="gap-2 text-[11px]"
|
||||
onclick={() => handleForkTheme(theme)}
|
||||
@@ -1725,9 +1843,10 @@
|
||||
Fork theme
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if canModerate}
|
||||
{#if owned || ($currentUser && !isLegacy && !theme.isHidden && !theme.isQuarantined)}
|
||||
{#if owned || canReport}
|
||||
<DropdownMenu.Separator class="my-1 bg-theme-800" />
|
||||
{/if}
|
||||
{#if !isLegacy}
|
||||
@@ -1810,27 +1929,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showModerationPanel}
|
||||
<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 panel"
|
||||
onclick={() => (showModerationPanel = false)}
|
||||
></button>
|
||||
<div
|
||||
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"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="moderation-title"
|
||||
<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>
|
||||
<h2 id="moderation-title" class="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<Dialog.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>
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class="mt-1 text-[11px] text-muted-foreground">
|
||||
Open reports and recent staff activity.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<WorkshopTooltip content="Send a system announcement">
|
||||
@@ -1841,6 +1952,7 @@
|
||||
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>
|
||||
@@ -1855,6 +1967,7 @@
|
||||
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>
|
||||
@@ -1868,6 +1981,7 @@
|
||||
size="icon"
|
||||
class="size-7 text-muted-foreground hover:text-white"
|
||||
onclick={() => (showModerationPanel = false)}
|
||||
aria-label="Close moderation panel"
|
||||
>
|
||||
<X class="size-4" />
|
||||
</Button>
|
||||
@@ -1885,28 +1999,51 @@
|
||||
|
||||
<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
|
||||
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"
|
||||
bind:value={moderationStatus}
|
||||
onchange={handleModerationFilterChange}
|
||||
<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"
|
||||
>
|
||||
<option value="open">Open reports</option>
|
||||
<option value="resolved">Resolved reports</option>
|
||||
<option value="dismissed">Dismissed reports</option>
|
||||
<option value="all">All reports</option>
|
||||
</select>
|
||||
<select
|
||||
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"
|
||||
bind:value={moderationCategory}
|
||||
onchange={handleModerationFilterChange}
|
||||
{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"
|
||||
>
|
||||
<option value="all">All categories</option>
|
||||
{#each REPORT_CATEGORIES as category}
|
||||
<option value={category.id}>{category.label}</option>
|
||||
{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>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Input
|
||||
class="h-8 border-theme-800 bg-theme-950/80 text-[11px]"
|
||||
placeholder="Filter by reason or theme..."
|
||||
@@ -1923,19 +2060,29 @@
|
||||
if (event.key === 'Enter') handleModerationFilterChange();
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
class="h-8 border-theme-800 bg-theme-950/80 text-[11px]"
|
||||
bind:value={moderationFrom}
|
||||
onchange={handleModerationFilterChange}
|
||||
aria-label="Reports from date"
|
||||
<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();
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
class="h-8 border-theme-800 bg-theme-950/80 text-[11px]"
|
||||
bind:value={moderationTo}
|
||||
onchange={handleModerationFilterChange}
|
||||
aria-label="Reports to date"
|
||||
<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">
|
||||
@@ -2011,22 +2158,23 @@
|
||||
<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
|
||||
<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}
|
||||
</span>
|
||||
</Badge>
|
||||
</h3>
|
||||
{#if selectableReports.length > 0}
|
||||
<label
|
||||
class="mt-2 flex w-fit cursor-pointer items-center gap-1.5 text-[10px] text-muted-foreground"
|
||||
<Label
|
||||
class="mt-2 flex w-fit cursor-pointer items-center gap-1.5 text-[10px] font-normal text-muted-foreground"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="size-3 accent-primary"
|
||||
<Checkbox
|
||||
checked={allSelectableReportsSelected}
|
||||
onchange={toggleAllReports}
|
||||
onCheckedChange={() => toggleAllReports()}
|
||||
/>
|
||||
Select all visible open reports
|
||||
</label>
|
||||
</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">
|
||||
@@ -2038,11 +2186,10 @@
|
||||
<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}
|
||||
<input
|
||||
type="checkbox"
|
||||
class="mt-0.5 size-3.5 shrink-0 accent-primary"
|
||||
<Checkbox
|
||||
class="mt-0.5 shrink-0"
|
||||
checked={selectedReportIds.includes(report.id)}
|
||||
onchange={() => toggleReportSelection(report.id)}
|
||||
onCheckedChange={() => toggleReportSelection(report.id)}
|
||||
aria-label={`Select report for ${report.themeName}`}
|
||||
/>
|
||||
{/if}
|
||||
@@ -2051,19 +2198,26 @@
|
||||
<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>
|
||||
<span
|
||||
class="rounded-full border border-theme-700/70 bg-theme-800/60 px-1.5 py-0.5 text-[9px] text-muted-foreground"
|
||||
<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)}
|
||||
</span>
|
||||
</Badge>
|
||||
{#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 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 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}
|
||||
</div>
|
||||
<p class="mt-0.5 text-[10px] text-muted-foreground">
|
||||
@@ -2178,16 +2332,28 @@
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
<select
|
||||
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"
|
||||
bind:value={moderationEventAction}
|
||||
onchange={handleAuditFilterChange}
|
||||
<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"
|
||||
>
|
||||
{#each AUDIT_ACTIONS as action}
|
||||
<option value={action.id}>{action.label}</option>
|
||||
{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>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Input
|
||||
class="h-7 w-36 border-theme-800 bg-theme-950/80 text-[10px]"
|
||||
placeholder="Filter by staff..."
|
||||
@@ -2254,9 +2420,8 @@
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
{#if announcementOpen}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
@@ -2452,6 +2617,140 @@
|
||||
</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 reportTarget}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<button
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
themeAssetBase,
|
||||
themesDirectory,
|
||||
toSafeName,
|
||||
updateInstalledThemeWorkshopId,
|
||||
} from '$lib/themes';
|
||||
import { publishThemeToWorkshop } from '$lib/communityThemes';
|
||||
import type { EZPPUser, EZPPUserInfo } from '$lib/types';
|
||||
@@ -3364,7 +3365,7 @@
|
||||
await saveTheme({ notify: false });
|
||||
}
|
||||
|
||||
publishThemeId = savedFolder || toSafeName(definition.name);
|
||||
publishThemeId = definition.workshopId ?? '';
|
||||
publishProgress = 0;
|
||||
publishStatusMessage = '';
|
||||
publishModalOpen = true;
|
||||
@@ -3412,9 +3413,10 @@
|
||||
const themeFolder = await path.join(baseThemesDir, savedFolder);
|
||||
const themeId = savedFolder || toSafeName(definition.name);
|
||||
|
||||
await publishThemeToWorkshop({
|
||||
const result = await publishThemeToWorkshop({
|
||||
themeFolder,
|
||||
themeId,
|
||||
workshopId: definition.workshopId,
|
||||
name: definition.name,
|
||||
description: definition.description || '',
|
||||
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({
|
||||
title: 'Published to 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">
|
||||
Workshop ID
|
||||
</span>
|
||||
{#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>
|
||||
<span class="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
@@ -6959,7 +6978,7 @@
|
||||
size="sm"
|
||||
class="bg-cyan-600 hover:bg-cyan-500 text-white font-medium gap-1.5"
|
||||
onclick={handlePublishToWorkshop}
|
||||
disabled={publishing || publishThemeId.trim().length < 2 || definition.name.trim().length === 0}
|
||||
disabled={publishing || definition.name.trim().length === 0}
|
||||
>
|
||||
{#if publishing}
|
||||
<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 = '',
|
||||
open = $bindable(false),
|
||||
id = undefined,
|
||||
ariaLabel = undefined,
|
||||
onValueChange = undefined,
|
||||
}: {
|
||||
value?: string;
|
||||
withTime?: boolean;
|
||||
@@ -29,6 +31,8 @@
|
||||
contentClass?: string;
|
||||
open?: boolean;
|
||||
id?: string;
|
||||
ariaLabel?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
} = $props();
|
||||
|
||||
let calendarValue = $state<DateValue | undefined>(undefined);
|
||||
@@ -70,6 +74,7 @@
|
||||
if (!calendarValue) {
|
||||
lastEmittedValue = '';
|
||||
value = '';
|
||||
onValueChange?.('');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -91,6 +96,7 @@
|
||||
|
||||
lastEmittedValue = next;
|
||||
value = next;
|
||||
onValueChange?.(next);
|
||||
}
|
||||
|
||||
function handleDateChange(newDate: DateValue | undefined) {
|
||||
@@ -147,6 +153,7 @@
|
||||
<Popover.Trigger
|
||||
{id}
|
||||
{disabled}
|
||||
aria-label={ariaLabel}
|
||||
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',
|
||||
!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} />
|
||||
@@ -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,
|
||||
};
|
||||
@@ -649,6 +649,8 @@ export type ThemeV2Definition = {
|
||||
description?: string;
|
||||
/** Workshop discovery tags for this theme. */
|
||||
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. */
|
||||
nsfw?: boolean;
|
||||
/** Optional preview image, relative to the theme's assets folder. */
|
||||
@@ -742,6 +744,15 @@ export const normalizeThemeTags = (raw: unknown): string[] => {
|
||||
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) =>
|
||||
Math.round(clamp(value, fallback, min, max));
|
||||
|
||||
@@ -1901,6 +1912,7 @@ export const normalizeThemeV2 = (raw: Partial<ThemeV2Definition>): ThemeV2Defini
|
||||
author: text(raw.author, 'Unknown'),
|
||||
description: text(raw.description),
|
||||
tags: normalizeThemeTags(raw.tags),
|
||||
workshopId: normalizeWorkshopId(raw.workshopId),
|
||||
nsfw: raw.nsfw === true,
|
||||
preview: text(raw.preview),
|
||||
background,
|
||||
|
||||
@@ -10,6 +10,8 @@ import * as fs from '@tauri-apps/plugin-fs';
|
||||
|
||||
export type Theme = {
|
||||
folder_name: string;
|
||||
/** Server-assigned workshop ID, when this theme has been published. */
|
||||
workshopId?: string;
|
||||
name: string;
|
||||
version: string;
|
||||
apiVersion: string;
|
||||
@@ -40,6 +42,7 @@ export type ThemeInfo = {
|
||||
name: string;
|
||||
version?: string;
|
||||
apiVersion?: string;
|
||||
workshopId?: string;
|
||||
author?: string;
|
||||
description?: string;
|
||||
nsfw?: boolean;
|
||||
@@ -165,6 +168,7 @@ const themeFromFolder = async (
|
||||
|
||||
return {
|
||||
folder_name: folderName,
|
||||
workshopId: typeof parsed.workshopId === 'string' ? parsed.workshopId : undefined,
|
||||
name: parsed.name,
|
||||
version: parsed.version ?? '1.0.0',
|
||||
apiVersion,
|
||||
@@ -559,6 +563,43 @@ export const saveThemeProject = async (
|
||||
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) => {
|
||||
const themeFolder = await path.join(await themesDirectory(), folderName);
|
||||
await invoke('export_theme', { themeFolder, destinationPath });
|
||||
|
||||
@@ -81,6 +81,51 @@
|
||||
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();
|
||||
|
||||
const editorWindow = (() => {
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
import DefaultThemePreview from '$assets/default_preview.png';
|
||||
import Logo from '$assets/logo.png';
|
||||
import { ezppfarm } from '$lib/api/ezpp';
|
||||
import {
|
||||
fetchCommunityThemeDetails,
|
||||
type CommunityTheme,
|
||||
} from '$lib/communityThemes';
|
||||
import DiagnosticsPanel from '$lib/components/diagnostics/DiagnosticsPanel.svelte';
|
||||
import LogViewer from '$lib/components/logs/LogViewer.svelte';
|
||||
import NotificationHub from '$lib/components/notifications/NotificationHub.svelte';
|
||||
import DiscordPresenceEditor from '$lib/components/presence/DiscordPresenceEditor.svelte';
|
||||
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
|
||||
import ThemeReportDialog from '$lib/components/theme/ThemeReportDialog.svelte';
|
||||
import ThemeV2Elements from '$lib/components/theme/ThemeV2Elements.svelte';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
@@ -158,6 +163,7 @@
|
||||
ChevronRight,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
Flag,
|
||||
Globe,
|
||||
Medal,
|
||||
TrendingDown,
|
||||
@@ -302,6 +308,9 @@
|
||||
};
|
||||
let restoreCandidate = $state<{ theme: Theme; backup: ThemeBackup } | undefined>(undefined);
|
||||
let restoringTheme = $state(false);
|
||||
let installedReportOpen = $state(false);
|
||||
let installedReportTarget = $state<CommunityTheme | null>(null);
|
||||
let installedReportLoading = $state(false);
|
||||
let logViewerOpen = $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) => {
|
||||
if (activeFolder === theme.folder_name) return 'Theme in use';
|
||||
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) => {
|
||||
if (theme.status !== 'installed') {
|
||||
const downloadThemeResult = await downloadTheme(theme);
|
||||
@@ -1972,6 +2019,8 @@
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
<ThemeReportDialog bind:open={installedReportOpen} theme={installedReportTarget} />
|
||||
|
||||
<div class="grid grid-cols-[0.085fr_1fr] h-screen relative" bind:this={dragAndDrop.ref}>
|
||||
{#if dragAndDrop.isDraggingOverApp && !droppedTheme}
|
||||
<div
|
||||
@@ -3993,6 +4042,19 @@
|
||||
{/if}
|
||||
</Button>
|
||||
{/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>
|
||||
|
||||
Reference in new issue
Block a user