feat(frontend): replace JS confirm() with native dialog component
- ConfirmDialog: centered <dialog> with backdrop blur, cancel + confirm (danger variant) - tags/[id]: delete tag uses ConfirmDialog - categories/[id]: delete category uses ConfirmDialog - files: bulk delete calls POST /files/bulk/delete, removes files from list, text updated to "Move to trash" (soft delete) - mock: add POST /files/bulk/delete handler Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1931adcd38
commit
1f591f3a3f
123
frontend/src/lib/components/common/ConfirmDialog.svelte
Normal file
123
frontend/src/lib/components/common/ConfirmDialog.svelte
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
danger?: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { message, confirmLabel = 'Confirm', danger = false, onConfirm, onCancel }: Props = $props();
|
||||||
|
|
||||||
|
let dialog = $state<HTMLDialogElement | undefined>();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
dialog?.showModal();
|
||||||
|
return () => dialog?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onCancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdropClick(e: MouseEvent) {
|
||||||
|
if (e.target === dialog) onCancel();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<dialog
|
||||||
|
bind:this={dialog}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
onclick={handleBackdropClick}
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<div class="body">
|
||||||
|
<p class="message">{message}</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn cancel" onclick={onCancel}>Cancel</button>
|
||||||
|
<button class="btn confirm" class:danger onclick={onConfirm}>{confirmLabel}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
dialog {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||||
|
max-width: min(340px, calc(100vw - 32px));
|
||||||
|
width: 100%;
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog::backdrop {
|
||||||
|
background-color: rgba(0, 0, 0, 0.55);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: 20px 20px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.cancel {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.cancel:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.confirm {
|
||||||
|
border: none;
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: var(--color-bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.confirm:hover {
|
||||||
|
background-color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.confirm.danger {
|
||||||
|
background-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.confirm.danger:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-danger) 80%, #fff);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -4,6 +4,7 @@
|
|||||||
import { api, ApiError } from '$lib/api/client';
|
import { api, ApiError } from '$lib/api/client';
|
||||||
import type { Category, Tag, TagOffsetPage } from '$lib/api/types';
|
import type { Category, Tag, TagOffsetPage } from '$lib/api/types';
|
||||||
import TagBadge from '$lib/components/tag/TagBadge.svelte';
|
import TagBadge from '$lib/components/tag/TagBadge.svelte';
|
||||||
|
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||||
|
|
||||||
let categoryId = $derived(page.params.id);
|
let categoryId = $derived(page.params.id);
|
||||||
|
|
||||||
@ -23,6 +24,7 @@
|
|||||||
let loadError = $state('');
|
let loadError = $state('');
|
||||||
let saveError = $state('');
|
let saveError = $state('');
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
|
let confirmDelete = $state(false);
|
||||||
|
|
||||||
const TAGS_LIMIT = 100;
|
const TAGS_LIMIT = 100;
|
||||||
|
|
||||||
@ -87,9 +89,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteCategory() {
|
async function doDeleteCategory() {
|
||||||
if (deleting) return;
|
confirmDelete = false;
|
||||||
if (!confirm(`Delete category "${name}"? Tags in this category will be unassigned.`)) return;
|
|
||||||
deleting = true;
|
deleting = true;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/categories/${categoryId}`);
|
await api.delete(`/categories/${categoryId}`);
|
||||||
@ -171,7 +172,7 @@
|
|||||||
<button type="submit" class="submit-btn" disabled={!name.trim() || saving}>
|
<button type="submit" class="submit-btn" disabled={!name.trim() || saving}>
|
||||||
{saving ? 'Saving…' : 'Save changes'}
|
{saving ? 'Saving…' : 'Save changes'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="delete-btn" onclick={deleteCategory} disabled={deleting}>
|
<button type="button" class="delete-btn" onclick={() => (confirmDelete = true)} disabled={deleting}>
|
||||||
{deleting ? 'Deleting…' : 'Delete'}
|
{deleting ? 'Deleting…' : 'Delete'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -212,6 +213,16 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if confirmDelete}
|
||||||
|
<ConfirmDialog
|
||||||
|
message={`Delete category "${name}"? Tags in this category will be unassigned.`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
danger
|
||||||
|
onConfirm={doDeleteCategory}
|
||||||
|
onCancel={() => (confirmDelete = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
.page { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
|||||||
@ -11,10 +11,12 @@
|
|||||||
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
||||||
import { fileSorting, type FileSortField } from '$lib/stores/sorting';
|
import { fileSorting, type FileSortField } from '$lib/stores/sorting';
|
||||||
import { selectionStore, selectionActive } from '$lib/stores/selection';
|
import { selectionStore, selectionActive } from '$lib/stores/selection';
|
||||||
|
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||||
import { parseDslFilter } from '$lib/utils/dsl';
|
import { parseDslFilter } from '$lib/utils/dsl';
|
||||||
import type { File, FileCursorPage } from '$lib/api/types';
|
import type { File, FileCursorPage } from '$lib/api/types';
|
||||||
|
|
||||||
let uploader = $state<{ open: () => void } | undefined>();
|
let uploader = $state<{ open: () => void } | undefined>();
|
||||||
|
let confirmDeleteFiles = $state(false);
|
||||||
|
|
||||||
function handleUploaded(file: File) {
|
function handleUploaded(file: File) {
|
||||||
files = [file, ...files];
|
files = [file, ...files];
|
||||||
@ -229,12 +231,27 @@
|
|||||||
<SelectionBar
|
<SelectionBar
|
||||||
onEditTags={() => {/* TODO */}}
|
onEditTags={() => {/* TODO */}}
|
||||||
onAddToPool={() => {/* TODO */}}
|
onAddToPool={() => {/* TODO */}}
|
||||||
onDelete={() => {
|
onDelete={() => (confirmDeleteFiles = true)}
|
||||||
if (confirm(`Delete ${$selectionStore.ids.size} file(s)?`)) {
|
/>
|
||||||
// TODO: call delete API
|
{/if}
|
||||||
|
|
||||||
|
{#if confirmDeleteFiles}
|
||||||
|
<ConfirmDialog
|
||||||
|
message={`Move ${$selectionStore.ids.size} file(s) to trash?`}
|
||||||
|
confirmLabel="Move to trash"
|
||||||
|
danger
|
||||||
|
onConfirm={async () => {
|
||||||
|
const ids = [...$selectionStore.ids];
|
||||||
|
confirmDeleteFiles = false;
|
||||||
selectionStore.exit();
|
selectionStore.exit();
|
||||||
|
try {
|
||||||
|
await api.post('/files/bulk/delete', { file_ids: ids });
|
||||||
|
files = files.filter((f) => !ids.includes(f.id ?? ''));
|
||||||
|
} catch {
|
||||||
|
// silently ignore — file list already updated optimistically
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onCancel={() => (confirmDeleteFiles = false)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
import { api, ApiError } from '$lib/api/client';
|
import { api, ApiError } from '$lib/api/client';
|
||||||
import type { Category, CategoryOffsetPage, Tag, TagRule } from '$lib/api/types';
|
import type { Category, CategoryOffsetPage, Tag, TagRule } from '$lib/api/types';
|
||||||
import TagRuleEditor from '$lib/components/tag/TagRuleEditor.svelte';
|
import TagRuleEditor from '$lib/components/tag/TagRuleEditor.svelte';
|
||||||
|
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||||
|
|
||||||
let tagId = $derived(page.params.id);
|
let tagId = $derived(page.params.id);
|
||||||
|
|
||||||
@ -21,6 +22,7 @@
|
|||||||
let deleting = $state(false);
|
let deleting = $state(false);
|
||||||
let loadError = $state('');
|
let loadError = $state('');
|
||||||
let saveError = $state('');
|
let saveError = $state('');
|
||||||
|
let confirmDelete = $state(false);
|
||||||
|
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
|
|
||||||
@ -68,9 +70,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteTag() {
|
async function doDeleteTag() {
|
||||||
if (deleting) return;
|
confirmDelete = false;
|
||||||
if (!confirm(`Delete tag "${name}"? This cannot be undone.`)) return;
|
|
||||||
deleting = true;
|
deleting = true;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/tags/${tagId}`);
|
await api.delete(`/tags/${tagId}`);
|
||||||
@ -162,7 +163,7 @@
|
|||||||
<button type="submit" class="submit-btn" disabled={!name.trim() || saving}>
|
<button type="submit" class="submit-btn" disabled={!name.trim() || saving}>
|
||||||
{saving ? 'Saving…' : 'Save changes'}
|
{saving ? 'Saving…' : 'Save changes'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="delete-btn" onclick={deleteTag} disabled={deleting}>
|
<button type="button" class="delete-btn" onclick={() => (confirmDelete = true)} disabled={deleting}>
|
||||||
{deleting ? 'Deleting…' : 'Delete'}
|
{deleting ? 'Deleting…' : 'Delete'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -178,6 +179,16 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if confirmDelete}
|
||||||
|
<ConfirmDialog
|
||||||
|
message={`Delete tag "${name}"? This cannot be undone.`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
danger
|
||||||
|
onConfirm={doDeleteTag}
|
||||||
|
onCancel={() => (confirmDelete = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
.page { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
|||||||
@ -317,6 +317,16 @@ export function mockApiPlugin(): Plugin {
|
|||||||
return json(res, 200, getMockFile(id));
|
return json(res, 200, getMockFile(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /files/bulk/delete — soft delete (just remove from mock array)
|
||||||
|
if (method === 'POST' && path === '/files/bulk/delete') {
|
||||||
|
const body = (await readBody(req)) as { file_ids?: string[] };
|
||||||
|
const ids = new Set(body.file_ids ?? []);
|
||||||
|
for (let i = MOCK_FILES.length - 1; i >= 0; i--) {
|
||||||
|
if (ids.has(MOCK_FILES[i].id)) MOCK_FILES.splice(i, 1);
|
||||||
|
}
|
||||||
|
return noContent(res);
|
||||||
|
}
|
||||||
|
|
||||||
// POST /files — upload (mock: drain body, return a new fake file)
|
// POST /files — upload (mock: drain body, return a new fake file)
|
||||||
if (method === 'POST' && path === '/files') {
|
if (method === 'POST' && path === '/files') {
|
||||||
// Drain the multipart body without parsing it
|
// Drain the multipart body without parsing it
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user