feat(workshop): confirm trash restore and permanent deletion

Add confirmation dialogs for restoring and permanently deleting trashed themes, and wire the permanent-delete API call.
This commit is contained in:
HorizonCode committed 2026-11-03 18:10:34 +01:00
1 parent 93536af6a6
commit 520ad5c4f1
2 files changed
+200 -9

No files matched your search

+19
View File
@@ -345,6 +345,25 @@ export async function deleteCommunityTheme(themeId: string): Promise<void> {
} }
} }
export async function permanentlyDeleteCommunityTheme(themeId: string): Promise<void> {
const baseUrl = await getThemesServiceUrl();
const headers = await getAuthHeaders();
if (!headers['X-Username'] || !headers['X-Password']) {
throw new Error('Please log in to permanently delete community themes.');
}
const res = await fetch(`${baseUrl}/v1/themes/${encodeURIComponent(themeId)}/permanent`, {
method: 'DELETE',
headers,
});
if (!res.ok) {
const errorJson = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(errorJson.error || `Failed to permanently delete theme (HTTP ${res.status})`);
}
}
export async function reportCommunityTheme( export async function reportCommunityTheme(
themeId: string, themeId: string,
reason: string, reason: string,
@@ -8,6 +8,7 @@
downloadAndInstallCommunityTheme, downloadAndInstallCommunityTheme,
forkCommunityTheme, forkCommunityTheme,
deleteCommunityTheme, deleteCommunityTheme,
permanentlyDeleteCommunityTheme,
restoreCommunityTheme, restoreCommunityTheme,
setCommunityThemeVisibility, setCommunityThemeVisibility,
setCommunityThemeQuarantine, setCommunityThemeQuarantine,
@@ -28,6 +29,7 @@
type ThemeReportQuery, type ThemeReportQuery,
type ThemeReportStatus, type ThemeReportStatus,
} from '$lib/communityThemes'; } from '$lib/communityThemes';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import Badge from '$lib/components/ui/badge/badge.svelte'; 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 { Checkbox } from '$lib/components/ui/checkbox';
@@ -121,6 +123,10 @@
let deletingId = $state<string | null>(null); let deletingId = $state<string | null>(null);
let moderatingId = $state<string | null>(null); let moderatingId = $state<string | null>(null);
let restoringId = $state<string | null>(null); let restoringId = $state<string | null>(null);
let purgingId = $state<string | null>(null);
let restoreTarget = $state<CommunityTheme | null>(null);
let purgeTarget = $state<CommunityTheme | null>(null);
let trashActionError = $state<string | null>(null);
let canModerate = $state(false); let canModerate = $state(false);
let showModerationPanel = $state(false); let showModerationPanel = $state(false);
@@ -199,6 +205,7 @@
restore: 'Visibility restored', restore: 'Visibility restored',
delete: 'Moved to trash', delete: 'Moved to trash',
restore_deleted: 'Restored from trash', restore_deleted: 'Restored from trash',
delete_permanent: 'Permanently deleted',
report: 'Reported', report: 'Reported',
report_resolved: 'Report resolved', report_resolved: 'Report resolved',
report_dismissed: 'Report dismissed', report_dismissed: 'Report dismissed',
@@ -216,6 +223,7 @@
{ id: 'report_dismissed', label: 'Report dismissed' }, { id: 'report_dismissed', label: 'Report dismissed' },
{ id: 'delete', label: 'Trashed' }, { id: 'delete', label: 'Trashed' },
{ id: 'restore_deleted', label: 'Restored from trash' }, { id: 'restore_deleted', label: 'Restored from trash' },
{ id: 'delete_permanent', label: 'Permanently deleted' },
]; ];
const localThemeMap = $derived.by(() => { const localThemeMap = $derived.by(() => {
@@ -780,30 +788,82 @@
} }
} }
async function handleRestoreTheme(theme: CommunityTheme) { function openRestoreDialog(theme: CommunityTheme) {
if (restoringId || downloadingId || forkingId || deletingId || moderatingId) return; if (restoringId || purgingId || downloadingId || forkingId || deletingId || moderatingId)
if (!window.confirm(`Restore "${theme.name}" to the workshop?`)) return; return;
purgeTarget = null;
trashActionError = null;
restoreTarget = theme;
}
function openPermanentDeleteDialog(theme: CommunityTheme) {
if (restoringId || purgingId || downloadingId || forkingId || deletingId || moderatingId)
return;
restoreTarget = null;
trashActionError = null;
purgeTarget = theme;
}
function closeRestoreDialog() {
if (restoringId) return;
restoreTarget = null;
trashActionError = null;
}
function closePermanentDeleteDialog() {
if (purgingId) return;
purgeTarget = null;
trashActionError = null;
}
async function handleRestoreTheme() {
const theme = restoreTarget;
if (!theme || restoringId || purgingId) return;
restoringId = theme.id; restoringId = theme.id;
trashActionError = null;
try { try {
await restoreCommunityTheme(theme.id); await restoreCommunityTheme(theme.id);
themes = themes.filter((item) => item.id !== theme.id); themes = themes.filter((item) => item.id !== theme.id);
totalThemes = Math.max(0, totalThemes - 1); totalThemes = Math.max(0, totalThemes - 1);
restoreTarget = null;
sileo.success({ sileo.success({
title: 'Theme restored', title: 'Theme restored',
description: `"${theme.name}" is back in the workshop.`, description: `"${theme.name}" is back in the workshop.`,
}); });
void loadWorkshopThemes(); void loadWorkshopThemes();
void loadWorkshopTags();
} catch (err) { } catch (err) {
sileo.error({ trashActionError = err instanceof Error ? err.message : String(err);
title: 'Could not restore theme',
description: err instanceof Error ? err.message : String(err),
});
} finally { } finally {
restoringId = null; restoringId = null;
} }
} }
async function handlePermanentDeleteTheme() {
const theme = purgeTarget;
if (!theme || restoringId || purgingId) return;
purgingId = theme.id;
trashActionError = null;
try {
await permanentlyDeleteCommunityTheme(theme.id);
themes = themes.filter((item) => item.id !== theme.id);
totalThemes = Math.max(0, totalThemes - 1);
purgeTarget = null;
sileo.success({
title: 'Theme permanently deleted',
description: `"${theme.name}" and its server files were removed.`,
});
void loadWorkshopThemes();
void loadWorkshopTags();
} catch (err) {
trashActionError = err instanceof Error ? err.message : String(err);
} finally {
purgingId = null;
}
}
function openReportDialog(theme: CommunityTheme) { function openReportDialog(theme: CommunityTheme) {
if (theme.deletedAt || theme.isHidden) return; if (theme.deletedAt || theme.isHidden) return;
if (!$currentUser) { if (!$currentUser) {
@@ -1866,8 +1926,8 @@
variant="secondary" variant="secondary"
size="sm" size="sm"
class="h-7 gap-1 text-xs" class="h-7 gap-1 text-xs"
onclick={() => handleRestoreTheme(theme)} onclick={() => openRestoreDialog(theme)}
disabled={restoringId === theme.id} disabled={restoringId === theme.id || purgingId === theme.id}
> >
{#if restoringId === theme.id} {#if restoringId === theme.id}
<LoaderCircle class="size-3 animate-spin" /> <LoaderCircle class="size-3 animate-spin" />
@@ -1877,6 +1937,21 @@
Restore Restore
{/if} {/if}
</Button> </Button>
<Button
variant="ghost"
size="sm"
class="h-7 gap-1 text-xs text-red-300 hover:bg-red-500/10 hover:text-red-200"
onclick={() => openPermanentDeleteDialog(theme)}
disabled={restoringId === theme.id || purgingId === theme.id}
>
{#if purgingId === theme.id}
<LoaderCircle class="size-3 animate-spin" />
Deleting...
{:else}
<Trash2 class="size-3" />
Delete forever
{/if}
</Button>
{:else} {:else}
<Badge <Badge
variant="outline" variant="outline"
@@ -3133,4 +3208,101 @@
</div> </div>
</div> </div>
{/if} {/if}
<AlertDialog.Root
open={restoreTarget !== null}
onOpenChange={(open) => {
if (!open) closeRestoreDialog();
}}
>
<AlertDialog.Content class="max-w-md border-theme-800 bg-theme-950 text-white">
<AlertDialog.Header>
<AlertDialog.Title class="flex items-center gap-2 text-base">
<RotateCcw class="size-4 text-cyan-300" /> Restore theme?
</AlertDialog.Title>
<AlertDialog.Description class="text-[11px] leading-relaxed text-muted-foreground">
Restore <span class="font-medium text-theme-100">{restoreTarget?.name}</span> to the workshop?
It will return with its existing visibility and moderation state.
</AlertDialog.Description>
</AlertDialog.Header>
{#if trashActionError}
<p class="rounded-md border border-red-900/50 bg-red-950/30 p-2 text-[11px] text-red-300">
{trashActionError}
</p>
{/if}
<AlertDialog.Footer class="gap-2">
<Button
variant="ghost"
size="sm"
onclick={closeRestoreDialog}
disabled={restoringId !== null}
>
Cancel
</Button>
<Button
size="sm"
class="gap-1.5"
onclick={() => void handleRestoreTheme()}
disabled={restoringId !== null}
>
{#if restoringId}
<LoaderCircle class="size-3.5 animate-spin" />
Restoring...
{:else}
<RotateCcw class="size-3.5" />
Restore theme
{/if}
</Button>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<AlertDialog.Root
open={purgeTarget !== null}
onOpenChange={(open) => {
if (!open) closePermanentDeleteDialog();
}}
>
<AlertDialog.Content class="max-w-md border-theme-800 bg-theme-950 text-white">
<AlertDialog.Header>
<AlertDialog.Title class="flex items-center gap-2 text-base">
<Trash2 class="size-4 text-red-400" /> Permanently delete theme?
</AlertDialog.Title>
<AlertDialog.Description class="text-[11px] leading-relaxed text-muted-foreground">
Permanently delete <span class="font-medium text-theme-100">{purgeTarget?.name}</span>?
This removes the theme package, preview, likes, downloads and reports from the server.
This cannot be undone. Installed copies on computers are not affected.
</AlertDialog.Description>
</AlertDialog.Header>
{#if trashActionError}
<p class="rounded-md border border-red-900/50 bg-red-950/30 p-2 text-[11px] text-red-300">
{trashActionError}
</p>
{/if}
<AlertDialog.Footer class="gap-2">
<Button
variant="ghost"
size="sm"
onclick={closePermanentDeleteDialog}
disabled={purgingId !== null}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
class="gap-1.5"
onclick={() => void handlePermanentDeleteTheme()}
disabled={purgingId !== null}
>
{#if purgingId}
<LoaderCircle class="size-3.5 animate-spin" />
Deleting...
{:else}
<Trash2 class="size-3.5" />
Delete permanently
{/if}
</Button>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
</Tooltip.Provider> </Tooltip.Provider>