From e97c3b9da082dc025eed3d8ef381328260ece4fa Mon Sep 17 00:00:00 2001
From: JakeBreath
Date: Thu, 17 Sep 2026 22:19:54 -0500
Subject: [PATCH] Toast action results and confirm destructive actions in one
dialog
Inline banners and per-row status text reported action results all over
the app; they are replaced by a small toast stack (bottom-right, Level 3
floating well styling) that only speaks for actions: successes fade,
errors stay until dismissed, and form-field validation stays inline.
Destructive actions no longer use bespoke inline confirm steps (the
library detail's Confirm delete button) or fire immediately (duplicate
copies/items, delete page selections, temp cleanup, upload discard, job
cancellation): they all go through one promise-based confirm dialog
(confirmAction) with a danger variant, Escape/backdrop to cancel.
---
ROADMAP.md | 3 +-
frontend/src/components/AppShell.tsx | 4 +
frontend/src/components/ConfirmDialog.tsx | 53 +++++++
frontend/src/components/Toasts.tsx | 75 ++++++++++
frontend/src/features/account/AccountPage.tsx | 51 ++-----
.../src/features/follows/PoolFollowButton.tsx | 6 +-
.../src/features/follows/TagFollowToggle.tsx | 9 +-
frontend/src/features/library/DeletePage.tsx | 52 ++++---
.../src/features/library/DuplicatesPage.tsx | 70 ++++++----
frontend/src/features/library/IqdbCard.tsx | 45 ++----
.../src/features/library/LibraryDetail.tsx | 131 ++++++------------
frontend/src/features/library/MatchPanel.tsx | 8 +-
frontend/src/features/online/OnlineDetail.tsx | 14 +-
frontend/src/features/online/OnlinePage.tsx | 7 +-
.../src/features/optimize/OptimizeModal.tsx | 8 +-
frontend/src/features/similar/SimilarPage.tsx | 17 +--
frontend/src/features/stats/StatsPage.tsx | 17 +--
frontend/src/features/upload/UploadPage.tsx | 29 ++--
frontend/src/features/users/UsersPage.tsx | 18 +--
frontend/src/store/confirm.ts | 40 ++++++
frontend/src/store/toasts.ts | 60 ++++++++
21 files changed, 430 insertions(+), 287 deletions(-)
create mode 100644 frontend/src/components/ConfirmDialog.tsx
create mode 100644 frontend/src/components/Toasts.tsx
create mode 100644 frontend/src/store/confirm.ts
create mode 100644 frontend/src/store/toasts.ts
diff --git a/ROADMAP.md b/ROADMAP.md
index 4b297fa..b18c662 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -129,7 +129,8 @@ Files now stage first and are resolved before entering the library.
- [x] 18+ entry screen: an age check gates the app before anything renders
(remembered per browser in `localStorage`)
-- [ ] Toasts instead of inline messages / confirm dialogs
+- [x] Toasts for action results (success/error) plus a shared confirm dialog for
+ destructive actions; form-field validation stays inline
- [ ] Mobile drawer polish for metadata panels (design spec §layout)
- [x] Profile pictures: staff Users page sets avatars from library J-IDs
(self-service picker in Account still pending)
diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx
index 9a9de70..0bf9ab3 100644
--- a/frontend/src/components/AppShell.tsx
+++ b/frontend/src/components/AppShell.tsx
@@ -4,8 +4,10 @@ import { useEffect, useState } from "react";
import { Link, NavLink, Outlet, useLocation, useNavigationType } from "react-router-dom";
import { CommandPalette } from "@/components/CommandPalette";
+import { ConfirmDialog } from "@/components/ConfirmDialog";
import { StatusFooter } from "@/components/StatusFooter";
import { StatusPill } from "@/components/StatusPill";
+import { Toasts } from "@/components/Toasts";
import { api } from "@/lib/api";
import { cn } from "@/lib/cn";
import type { SystemStatus } from "@/lib/types";
@@ -182,6 +184,8 @@ export function AppShell() {
)}
/>
setPaletteOpen(false)} />
+
+
);
}
diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx
new file mode 100644
index 0000000..3dd81f7
--- /dev/null
+++ b/frontend/src/components/ConfirmDialog.tsx
@@ -0,0 +1,53 @@
+import { useEffect } from "react";
+
+import { Button } from "@/components/ui";
+import { useConfirm } from "@/store/confirm";
+
+export function ConfirmDialog() {
+ const request = useConfirm((state) => state.request);
+ const settle = useConfirm((state) => state.settle);
+
+ useEffect(() => {
+ if (!request) return;
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") settle(false);
+ }
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [request, settle]);
+
+ if (!request) return null;
+
+ return (
+ settle(false)}
+ >
+
event.stopPropagation()}
+ >
+
{request.title}
+ {request.description ? (
+
+ {request.description}
+
+ ) : null}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/Toasts.tsx b/frontend/src/components/Toasts.tsx
new file mode 100644
index 0000000..729dc45
--- /dev/null
+++ b/frontend/src/components/Toasts.tsx
@@ -0,0 +1,75 @@
+import { AlertTriangle, CheckCircle2, Info, X } from "lucide-react";
+import type { LucideIcon } from "lucide-react";
+
+import { cn } from "@/lib/cn";
+import { useToasts, type ToastKind } from "@/store/toasts";
+
+const KIND_STYLES: Record = {
+ ok: "border-ctp-green/40",
+ error: "border-ctp-red/40",
+ info: "border-ctp-blue/40",
+};
+
+const KIND_ICONS: Record = {
+ ok: CheckCircle2,
+ error: AlertTriangle,
+ info: Info,
+};
+
+const ICON_STYLES: Record = {
+ ok: "text-ctp-green",
+ error: "text-ctp-red",
+ info: "text-ctp-blue",
+};
+
+export function Toasts() {
+ const toasts = useToasts((state) => state.toasts);
+ const dismiss = useToasts((state) => state.dismiss);
+
+ if (toasts.length === 0) return null;
+
+ return (
+
+ {toasts.map((item) => {
+ const Icon = KIND_ICONS[item.kind];
+ return (
+
+
+
{item.text}
+ {item.action ? (
+
+ ) : null}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/src/features/account/AccountPage.tsx b/frontend/src/features/account/AccountPage.tsx
index 88c148a..66e2f95 100644
--- a/frontend/src/features/account/AccountPage.tsx
+++ b/frontend/src/features/account/AccountPage.tsx
@@ -1,4 +1,4 @@
-import { CheckCircle2, Eye, EyeOff, XCircle } from "lucide-react";
+import { Eye, EyeOff } from "lucide-react";
import { useEffect, useState } from "react";
import { Button, Field, Spinner, inputClass } from "@/components/ui";
@@ -11,6 +11,7 @@ import {
} from "@/lib/e621";
import { useAuth } from "@/store/auth";
import { useE621 } from "@/store/e621";
+import { toast } from "@/store/toasts";
const BASE_URL_OPTIONS = [
{ value: "https://e621.net", label: "e621.net — main site" },
@@ -33,13 +34,7 @@ function AccountForm({ credentials }: { credentials: E621Credentials | null }) {
const [customBase, setCustomBase] = useState(savedBaseIsKnown ? "" : savedBase);
const [showKey, setShowKey] = useState(false);
const [saving, setSaving] = useState(false);
- const [saveMessage, setSaveMessage] = useState(null);
- const [saveError, setSaveError] = useState(null);
const [testing, setTesting] = useState(false);
- const [testResult, setTestResult] = useState<{
- ok: boolean;
- message: string;
- } | null>(null);
const baseUrl = (baseChoice === "custom" ? customBase.trim() : baseChoice).replace(
/\/+$/,
@@ -53,13 +48,11 @@ function AccountForm({ credentials }: { credentials: E621Credentials | null }) {
async function handleSave() {
setSaving(true);
- setSaveMessage(null);
- setSaveError(null);
try {
await save(values);
- setSaveMessage("Credentials saved.");
+ toast.ok("e621 credentials saved.");
} catch (error) {
- setSaveError(errorMessage(error));
+ toast.error(errorMessage(error));
} finally {
setSaving(false);
}
@@ -67,19 +60,17 @@ function AccountForm({ credentials }: { credentials: E621Credentials | null }) {
async function handleTest() {
setTesting(true);
- setTestResult(null);
try {
const user = await verifyE621Credentials({ ...values, configured: true });
- setTestResult({
- ok: true,
- message: `Connected as ${user.name}${
+ toast.ok(
+ `Connected as ${user.name}${
typeof user.favorite_count === "number"
? ` — ${user.favorite_count} favorites`
: ""
}.`,
- });
+ );
} catch (error) {
- setTestResult({ ok: false, message: e621ErrorMessage(error) });
+ toast.error(e621ErrorMessage(error));
} finally {
setTesting(false);
}
@@ -167,32 +158,6 @@ function AccountForm({ credentials }: { credentials: E621Credentials | null }) {
- {saveError ? (
-
- {saveError}
-
- ) : null}
- {saveMessage ? (
- {saveMessage}
- ) : null}
- {testResult ? (
-
- {testResult.ok ? (
-
- ) : (
-
- )}
- {testResult.message}
-
- ) : null}
-
- {error ?
{error}
: null}
);
}
diff --git a/frontend/src/features/follows/TagFollowToggle.tsx b/frontend/src/features/follows/TagFollowToggle.tsx
index bbeda0b..2a50363 100644
--- a/frontend/src/features/follows/TagFollowToggle.tsx
+++ b/frontend/src/features/follows/TagFollowToggle.tsx
@@ -6,11 +6,11 @@ import { api, errorMessage } from "@/lib/api";
import { cn } from "@/lib/cn";
import type { FollowedTag, Paginated } from "@/lib/types";
import { useAuth } from "@/store/auth";
+import { toast } from "@/store/toasts";
export function TagFollowToggle({ tag }: { tag: string }) {
const user = useAuth((state) => state.user);
const queryClient = useQueryClient();
- const [error, setError] = useState(null);
// What the chip should show once the request and refetch settle.
const [target, setTarget] = useState(null);
@@ -34,14 +34,13 @@ export function TagFollowToggle({ tag }: { tag: string }) {
json: { tag: normalized },
}),
onSuccess: () => {
- setError(null);
void queryClient.invalidateQueries({ queryKey: ["follows-tags"] });
void queryClient.invalidateQueries({ queryKey: ["follows-feed"] });
void queryClient.invalidateQueries({ queryKey: ["follows-cloud"] });
},
onError: (err) => {
setTarget(null);
- setError(errorMessage(err));
+ toast.error(errorMessage(err));
},
});
@@ -72,11 +71,11 @@ export function TagFollowToggle({ tag }: { tag: string }) {
event.preventDefault();
}}
disabled={busy || query.isPending}
- title={error ?? (followed ? `Unfollow ${tag}` : `Follow ${tag}`)}
+ title={followed ? `Unfollow ${tag}` : `Follow ${tag}`}
aria-label={followed ? `Unfollow ${tag}` : `Follow ${tag}`}
className={cn(
"-mr-0.5 inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm transition",
- error ? "text-ctp-red" : "opacity-70 hover:opacity-100",
+ "opacity-70 hover:opacity-100",
query.isPending && "opacity-40",
)}
>
diff --git a/frontend/src/features/library/DeletePage.tsx b/frontend/src/features/library/DeletePage.tsx
index f988e1c..9372e9b 100644
--- a/frontend/src/features/library/DeletePage.tsx
+++ b/frontend/src/features/library/DeletePage.tsx
@@ -7,6 +7,8 @@ import { api, errorMessage } from "@/lib/api";
import { cn } from "@/lib/cn";
import { formatBytes } from "@/lib/format";
import type { MediaItem, Paginated, StorageInfo } from "@/lib/types";
+import { confirmAction } from "@/store/confirm";
+import { toast } from "@/store/toasts";
function parseJIds(text: string): string[] {
return text
@@ -20,7 +22,6 @@ export default function DeletePage() {
const [jIdsInput, setJIdsInput] = useState("");
const [loadedIds, setLoadedIds] = useState([]);
const [selected, setSelected] = useState([]);
- const [status, setStatus] = useState(null);
const storageQuery = useQuery({
queryKey: ["storage"],
@@ -43,28 +44,50 @@ export default function DeletePage() {
onSuccess: (result) => {
setSelected([]);
setLoadedIds([]);
- setStatus(
- `Deleted ${result.deleted.length} item(s)${
- result.errors.length > 0 ? ` · ${result.errors.length} failed` : ""
- }.`,
- );
+ toast.ok(`Deleted ${result.deleted.length} item(s).`);
+ if (result.errors.length > 0) {
+ toast.error(
+ `${result.errors.length} item(s) could not be deleted: ` +
+ result.errors.map((entry) => entry.error).join(", "),
+ );
+ }
void queryClient.invalidateQueries({ queryKey: ["storage"] });
void queryClient.invalidateQueries({ queryKey: ["files"] });
void queryClient.invalidateQueries({ queryKey: ["duplicates-md5"] });
},
- onError: (error) => setStatus(errorMessage(error)),
+ onError: (error) => toast.error(errorMessage(error)),
});
const tempMutation = useMutation({
mutationFn: () =>
api<{ removed: number }>("/api/temp/clear/", { method: "POST" }),
onSuccess: (result) => {
- setStatus(`Removed ${result.removed} temp file(s).`);
+ toast.ok(`Removed ${result.removed} temp file(s).`);
void queryClient.invalidateQueries({ queryKey: ["storage"] });
},
- onError: (error) => setStatus(errorMessage(error)),
+ onError: (error) => toast.error(errorMessage(error)),
});
+ async function handleDeleteSelected() {
+ const confirmed = await confirmAction({
+ title: `Delete ${selected.length} item(s)?`,
+ description: "They are removed from the library along with all of their copies.",
+ confirmLabel: "Delete",
+ danger: true,
+ });
+ if (confirmed) deleteMutation.mutate(selected);
+ }
+
+ async function handleClearTemp() {
+ const confirmed = await confirmAction({
+ title: "Clear the temp folder?",
+ description: "Staged uploads still sitting in it will be removed.",
+ confirmLabel: "Clear",
+ danger: true,
+ });
+ if (confirmed) tempMutation.mutate();
+ }
+
function toggleSelected(jId: string) {
setSelected((current) =>
current.includes(jId)
@@ -84,12 +107,6 @@ export default function DeletePage() {
- {status ? (
-
- {status}
-
- ) : null}
-
{storageQuery.isPending ? (
@@ -157,7 +174,7 @@ export default function DeletePage() {
variant="secondary"
className="mt-2 px-2 py-1 text-xs"
disabled={tempMutation.isPending || storage.temp.files === 0}
- onClick={() => tempMutation.mutate()}
+ onClick={() => void handleClearTemp()}
>
{tempMutation.isPending ? "Clearing…" : "Clear temp folder"}
@@ -182,7 +199,6 @@ export default function DeletePage() {
);
}
@@ -392,7 +362,9 @@ function CustomDataCard({ item, canEdit }: { item: MediaItem; canEdit: boolean }
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["file", item.j_id] });
void queryClient.invalidateQueries({ queryKey: ["files"] });
+ toast.ok("Custom data saved.");
},
+ onError: (error) => toast.error(errorMessage(error)),
});
if (!canEdit) {
@@ -476,12 +448,6 @@ function CustomDataCard({ item, canEdit }: { item: MediaItem; canEdit: boolean }
onChange={(event) => setNotes(event.target.value)}
/>
- {mutation.isError ? (
- {errorMessage(mutation.error)}
- ) : mutation.isSuccess ? (
- Saved.
- ) : null}
-
{mutation.isPending ? "Saving…" : "Save"}
@@ -495,7 +461,6 @@ export function LibraryDetail({ jId }: { jId: string }) {
const queryClient = useQueryClient();
const ordering = useUi((state) => state.ordering);
const user = useAuth((state) => state.user);
- const [confirmDelete, setConfirmDelete] = useState(false);
const [optimizeOpen, setOptimizeOpen] = useState(false);
const query = useQuery({
@@ -522,14 +487,29 @@ export function LibraryDetail({ jId }: { jId: string }) {
"/api/delete/",
{ method: "POST", json: { j_ids: [jId] } },
),
- onSuccess: () => {
+ onSuccess: (data) => {
void queryClient.invalidateQueries({ queryKey: ["files"] });
void queryClient.invalidateQueries({ queryKey: ["duplicates-md5"] });
void queryClient.invalidateQueries({ queryKey: ["storage"] });
+ toast.ok(`${data.deleted[0] ?? jId} deleted from the library.`);
+ if (data.errors.length > 0) {
+ toast.error(data.errors.map((entry) => entry.error).join(", "));
+ }
navigate("/");
},
+ onError: (error) => toast.error(errorMessage(error)),
});
+ async function handleDelete() {
+ const confirmed = await confirmAction({
+ title: `Delete ${jId}?`,
+ description: "This removes the item and all of its copies from the library.",
+ confirmLabel: "Delete",
+ danger: true,
+ });
+ if (confirmed) deleteMutation.mutate();
+ }
+
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.ctrlKey || event.metaKey || event.altKey) return;
@@ -615,40 +595,15 @@ export function LibraryDetail({ jId }: { jId: string }) {
) : null}
{canDelete ? (
- confirmDelete ? (
- <>
-
- Delete this item and all its copies?
-
- deleteMutation.mutate()}
- >
- {deleteMutation.isPending ? "Deleting…" : "Confirm delete"}
-
- setConfirmDelete(false)}
- disabled={deleteMutation.isPending}
- >
- Cancel
-
- >
- ) : (
- setConfirmDelete(true)}
- title="Delete this item from the library"
- >
- Delete
-
- )
- ) : null}
- {deleteMutation.isError ? (
-
- {errorMessage(deleteMutation.error)}
-
+ void handleDelete()}
+ title="Delete this item from the library"
+ >
+
+ {deleteMutation.isPending ? "Deleting…" : "Delete"}
+
) : null}
}
diff --git a/frontend/src/features/library/MatchPanel.tsx b/frontend/src/features/library/MatchPanel.tsx
index 7918228..d9064d9 100644
--- a/frontend/src/features/library/MatchPanel.tsx
+++ b/frontend/src/features/library/MatchPanel.tsx
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import { Button, Spinner } from "@/components/ui";
import { api, errorMessage } from "@/lib/api";
import type { MatchTask } from "@/lib/types";
+import { toast } from "@/store/toasts";
type Scope = "missing" | "all";
@@ -50,6 +51,7 @@ export function MatchPanel() {
setTaskId(data.task_id);
setMenuOpen(false);
},
+ onError: (error) => toast.error(errorMessage(error)),
});
const cancelMutation = useMutation({
@@ -57,6 +59,7 @@ export function MatchPanel() {
api<{ success: boolean }>(`/api/matches/${taskId}/cancel/`, {
method: "POST",
}),
+ onError: (error) => toast.error(errorMessage(error)),
});
if (running && task) {
@@ -146,11 +149,6 @@ export function MatchPanel() {
Refreshes matched metadata and flags deleted posts too.
- {startMutation.isError ? (
-
- {errorMessage(startMutation.error)}
-
- ) : null}
) : null}
diff --git a/frontend/src/features/online/OnlineDetail.tsx b/frontend/src/features/online/OnlineDetail.tsx
index 8f31eed..f1b322a 100644
--- a/frontend/src/features/online/OnlineDetail.tsx
+++ b/frontend/src/features/online/OnlineDetail.tsx
@@ -31,6 +31,7 @@ import { formatBytes, formatDate } from "@/lib/format";
import type { DownloadTask, Paginated } from "@/lib/types";
import { useAuth } from "@/store/auth";
import { useE621 } from "@/store/e621";
+import { toast } from "@/store/toasts";
const ratingText: Record = {
s: "text-ctp-green",
@@ -115,6 +116,7 @@ export function OnlineDetail({ postId }: { postId: number }) {
queryKey: ["e621", "post", postId],
});
},
+ onError: (error) => toast.error(e621ErrorMessage(error)),
});
const [downloadTaskId, setDownloadTaskId] = useState(null);
@@ -166,6 +168,7 @@ export function OnlineDetail({ postId }: { postId: number }) {
setDownloadTaskId(task.task_id);
void queryClient.invalidateQueries({ queryKey: ["status"] });
},
+ onError: (error) => toast.error(errorMessage(error)),
});
const taskQuery = useQuery({
@@ -400,17 +403,6 @@ export function OnlineDetail({ postId }: { postId: number }) {
) : null}
) : null}
-
- {favoriteMutation.isError ? (
-
- {e621ErrorMessage(favoriteMutation.error)}
-
- ) : null}
- {downloadMutation.isError ? (
-
- {errorMessage(downloadMutation.error)}
-
- ) : null}
>
}
media={
diff --git a/frontend/src/features/online/OnlinePage.tsx b/frontend/src/features/online/OnlinePage.tsx
index 4b6dc49..a1cbcee 100644
--- a/frontend/src/features/online/OnlinePage.tsx
+++ b/frontend/src/features/online/OnlinePage.tsx
@@ -18,6 +18,7 @@ import {
type E621Post,
} from "@/lib/e621";
import { useE621 } from "@/store/e621";
+import { toast } from "@/store/toasts";
const PAGE_SIZE = 48;
const EMPTY_POSTS: E621Post[] = [];
@@ -48,6 +49,7 @@ export default function OnlinePage() {
void queryClient.invalidateQueries({ queryKey: ["follows-cloud"] });
});
},
+ onError: (error) => toast.error(e621ErrorMessage(error)),
});
const blacklistEntries = useMemo(
@@ -270,11 +272,6 @@ export default function OnlinePage() {
)}
- {blacklistMutation.isError ? (
-
- {e621ErrorMessage(blacklistMutation.error)}
-
- ) : null}
Saved to your e621 account.
diff --git a/frontend/src/features/optimize/OptimizeModal.tsx b/frontend/src/features/optimize/OptimizeModal.tsx
index 7c14aa0..858e305 100644
--- a/frontend/src/features/optimize/OptimizeModal.tsx
+++ b/frontend/src/features/optimize/OptimizeModal.tsx
@@ -19,6 +19,7 @@ import {
type OptimizeOptions,
} from "./types";
import { useOptimizer } from "./useOptimizer";
+import { toast } from "@/store/toasts";
const DIMENSION_CHOICES: { value: number | null; label: string }[] = [
{ value: null, label: "Keep original" },
@@ -248,12 +249,17 @@ export function OptimizeModal({
if (result?.storageKey) void deleteStoredFile(result.storageKey);
setApplied(true);
setApplyError(null);
+ toast.ok(`${item.j_id} optimized.`);
void queryClient.invalidateQueries({ queryKey: ["file", item.j_id] });
void queryClient.invalidateQueries({ queryKey: ["files"] });
void queryClient.invalidateQueries({ queryKey: ["storage"] });
void queryClient.invalidateQueries({ queryKey: ["duplicates-md5"] });
},
- onError: (err) => setApplyError(errorMessage(err)),
+ onError: (err) => {
+ const message = errorMessage(err);
+ setApplyError(message);
+ toast.error(message);
+ },
});
const originalSize = item.size;
diff --git a/frontend/src/features/similar/SimilarPage.tsx b/frontend/src/features/similar/SimilarPage.tsx
index e1eeb8e..2ff5b92 100644
--- a/frontend/src/features/similar/SimilarPage.tsx
+++ b/frontend/src/features/similar/SimilarPage.tsx
@@ -21,6 +21,7 @@ import {
import { formatBytes, formatDate } from "@/lib/format";
import type { SimilarityCheck } from "@/lib/types";
import { useE621 } from "@/store/e621";
+import { toast } from "@/store/toasts";
const VIDEO_RE = /\.(mp4|webm)$/i;
@@ -39,12 +40,10 @@ export default function SimilarPage() {
const [check, setCheck] = useState(null);
const [uploading, setUploading] = useState(false);
const [percent, setPercent] = useState(0);
- const [error, setError] = useState(null);
const [dragging, setDragging] = useState(false);
const [iqdb, setIqdb] = useState(null);
const [iqdbBusy, setIqdbBusy] = useState(false);
- const [iqdbError, setIqdbError] = useState(null);
const [selected, setSelected] = useState(null);
const configured = Boolean(credentials?.configured);
@@ -57,10 +56,10 @@ export default function SimilarPage() {
setFile(null);
setIqdb(null);
setSelected(null);
- setError(null);
- setIqdbError(null);
if (inputRef.current) inputRef.current.value = "";
+ toast.ok("Temporary copy deleted.");
},
+ onError: (error) => toast.error(errorMessage(error)),
});
async function startCheck(selectedFile: File) {
@@ -68,8 +67,6 @@ export default function SimilarPage() {
setCheck(null);
setIqdb(null);
setSelected(null);
- setError(null);
- setIqdbError(null);
setPercent(0);
setUploading(true);
try {
@@ -82,7 +79,7 @@ export default function SimilarPage() {
setCheck(data);
void queryClient.invalidateQueries({ queryKey: ["files"] });
} catch (err) {
- setError(errorMessage(err));
+ toast.error(errorMessage(err));
} finally {
setUploading(false);
}
@@ -98,7 +95,7 @@ export default function SimilarPage() {
);
setIqdb(results);
} catch (err) {
- setIqdbError(e621ErrorMessage(err));
+ toast.error(e621ErrorMessage(err));
} finally {
setIqdbBusy(false);
}
@@ -183,8 +180,6 @@ export default function SimilarPage() {
) : null}
- {error ? {error}
: null}
-
{check ? (
-
- {message ? (
- {message}
- ) : null}
- {error ? (
- {error}
- ) : null}
);
}
diff --git a/frontend/src/store/confirm.ts b/frontend/src/store/confirm.ts
new file mode 100644
index 0000000..886ddd4
--- /dev/null
+++ b/frontend/src/store/confirm.ts
@@ -0,0 +1,40 @@
+import { create } from "zustand";
+
+export interface ConfirmOptions {
+ title: string;
+ description?: string;
+ confirmLabel?: string;
+ cancelLabel?: string;
+ danger?: boolean;
+}
+
+interface ConfirmRequest extends ConfirmOptions {
+ resolve: (confirmed: boolean) => void;
+}
+
+interface ConfirmState {
+ request: ConfirmRequest | null;
+ open: (options: ConfirmOptions) => Promise;
+ settle: (confirmed: boolean) => void;
+}
+
+export const useConfirm = create((set, get) => ({
+ request: null,
+ open: (options) =>
+ new Promise((resolve) => {
+ set({ request: { ...options, resolve } });
+ }),
+ settle: (confirmed) => {
+ get().request?.resolve(confirmed);
+ set({ request: null });
+ },
+}));
+
+/**
+ * Ask for confirmation before a destructive action and await the answer:
+ *
+ * if (!(await confirmAction({ title: "Delete J-42?", danger: true }))) return;
+ */
+export function confirmAction(options: ConfirmOptions): Promise {
+ return useConfirm.getState().open(options);
+}
diff --git a/frontend/src/store/toasts.ts b/frontend/src/store/toasts.ts
new file mode 100644
index 0000000..e866fb9
--- /dev/null
+++ b/frontend/src/store/toasts.ts
@@ -0,0 +1,60 @@
+import { create } from "zustand";
+
+export type ToastKind = "ok" | "error" | "info";
+
+export interface ToastAction {
+ label: string;
+ onClick: () => void;
+}
+
+export interface Toast {
+ id: number;
+ kind: ToastKind;
+ text: string;
+ action?: ToastAction;
+}
+
+interface ToastState {
+ toasts: Toast[];
+ push: (kind: ToastKind, text: string, action?: ToastAction) => number;
+ dismiss: (id: number) => void;
+}
+
+/** Errors stay until dismissed; successes and info fade on their own. */
+const DURATIONS: Record = {
+ ok: 4_000,
+ info: 5_000,
+ error: null,
+};
+
+let nextId = 1;
+
+export const useToasts = create((set, get) => ({
+ toasts: [],
+ push: (kind, text, action) => {
+ const existing = get().toasts.find(
+ (toast) => toast.kind === kind && toast.text === text,
+ );
+ if (existing) return existing.id;
+
+ const id = nextId++;
+ set((state) => ({ toasts: [...state.toasts, { id, kind, text, action }] }));
+ const duration = DURATIONS[kind];
+ if (duration !== null) {
+ window.setTimeout(() => get().dismiss(id), duration);
+ }
+ return id;
+ },
+ dismiss: (id) =>
+ set((state) => ({ toasts: state.toasts.filter((toast) => toast.id !== id) })),
+}));
+
+/** Fire-and-forget helper usable outside React components. */
+export const toast = {
+ ok: (text: string, action?: ToastAction) =>
+ useToasts.getState().push("ok", text, action),
+ error: (text: string, action?: ToastAction) =>
+ useToasts.getState().push("error", text, action),
+ info: (text: string, action?: ToastAction) =>
+ useToasts.getState().push("info", text, action),
+};