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%
|
%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>
|
||||||
@@ -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));
|
||||||
|
|
||||||
|
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({
|
sileo.success({
|
||||||
title: 'Theme tags updated',
|
title: 'Theme tags updated',
|
||||||
description: updated.tags.length > 0
|
description: installed
|
||||||
? `Tags: ${updated.tags.map((tag) => `#${tag}`).join(', ')}`
|
? `Workshop and installed theme.json tags updated. ${tagSummary}`
|
||||||
: 'All tags were removed.',
|
: `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}
|
|
||||||
<LoaderCircle class="size-3.5 animate-spin" />
|
|
||||||
Saving tags...
|
|
||||||
{:else}
|
|
||||||
<Tag class="size-3.5" />
|
<Tag class="size-3.5" />
|
||||||
Edit tags
|
Edit tags
|
||||||
{/if}
|
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
{#if !isLegacy && isInstalled && hasUpdate}
|
{#if !isLegacy && isInstalled && hasUpdate}
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
@@ -1709,7 +1824,8 @@
|
|||||||
Refresh installed copy
|
Refresh installed copy
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if $currentUser && !isLegacy && !theme.isHidden && !theme.isQuarantined}
|
{:else if canReport || canFork}
|
||||||
|
{#if canReport}
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
class="gap-2 text-[11px]"
|
class="gap-2 text-[11px]"
|
||||||
onclick={() => openReportDialog(theme)}
|
onclick={() => openReportDialog(theme)}
|
||||||
@@ -1717,6 +1833,8 @@
|
|||||||
<Flag class="size-3.5" />
|
<Flag class="size-3.5" />
|
||||||
Report theme
|
Report theme
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
{/if}
|
||||||
|
{#if canFork}
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
class="gap-2 text-[11px]"
|
class="gap-2 text-[11px]"
|
||||||
onclick={() => handleForkTheme(theme)}
|
onclick={() => handleForkTheme(theme)}
|
||||||
@@ -1725,9 +1843,10 @@
|
|||||||
Fork theme
|
Fork theme
|
||||||
</DropdownMenu.Item>
|
</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,27 +1929,19 @@
|
|||||||
</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"
|
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between gap-3 border-b border-theme-800/70 p-4">
|
<div class="flex items-start justify-between gap-3 border-b border-theme-800/70 p-4">
|
||||||
<div>
|
<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" />
|
<Flag class="size-4 text-amber-300" />
|
||||||
Moderation
|
Moderation
|
||||||
</h2>
|
</Dialog.Title>
|
||||||
<p class="mt-1 text-[11px] text-muted-foreground">Open reports and recent staff activity.</p>
|
<Dialog.Description class="mt-1 text-[11px] text-muted-foreground">
|
||||||
|
Open reports and recent staff activity.
|
||||||
|
</Dialog.Description>
|
||||||
</div>
|
</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">
|
||||||
@@ -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) => {
|
||||||
|
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"
|
aria-label="Report status filter"
|
||||||
>
|
>
|
||||||
<option value="open">Open reports</option>
|
{REPORT_STATUS_OPTIONS.find((option) => option.id === moderationStatus)?.label ??
|
||||||
<option value="resolved">Resolved reports</option>
|
'Open reports'}
|
||||||
<option value="dismissed">Dismissed reports</option>
|
</Select.Trigger>
|
||||||
<option value="all">All reports</option>
|
<Select.Content class="border-theme-800 bg-theme-950">
|
||||||
</select>
|
{#each REPORT_STATUS_OPTIONS as option (option.id)}
|
||||||
<select
|
<Select.Item value={option.id} label={option.label} />
|
||||||
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"
|
{/each}
|
||||||
bind:value={moderationCategory}
|
</Select.Content>
|
||||||
onchange={handleModerationFilterChange}
|
</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"
|
aria-label="Report category filter"
|
||||||
>
|
>
|
||||||
<option value="all">All categories</option>
|
{REPORT_CATEGORIES.find((category) => category.id === moderationCategory)?.label ??
|
||||||
{#each REPORT_CATEGORIES as category}
|
'All categories'}
|
||||||
<option value={category.id}>{category.label}</option>
|
</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}
|
{/each}
|
||||||
</select>
|
</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) => {
|
||||||
|
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"
|
aria-label="Audit action filter"
|
||||||
>
|
>
|
||||||
{#each AUDIT_ACTIONS as action}
|
{AUDIT_ACTIONS.find((action) => action.id === moderationEventAction)?.label ??
|
||||||
<option value={action.id}>{action.label}</option>
|
'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}
|
{/each}
|
||||||
</select>
|
</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>
|
||||||
|
{#if publishThemeId}
|
||||||
<p class="mt-1 truncate font-mono text-[11px] text-theme-100">{publishThemeId}</p>
|
<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} />
|
||||||
@@ -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;
|
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,
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 = (() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user