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:
1 parent
93536af6a6
commit
520ad5c4f1
2 files changed
+200
-9
No files matched your search
@@ -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>
|
||||||
Reference in new issue
Block a user