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() { @@ -347,11 +374,6 @@ export default function DuplicatesPage() { > {similarMutation.isPending ? "Searching…" : "Search"} - {similarMutation.isError ? ( -

- {errorMessage(similarMutation.error)} -

- ) : null} {similarMutation.data ? ( @@ -460,7 +482,7 @@ export default function DuplicatesPage() { variant="danger" className="px-2 py-1 text-xs" disabled={deleteMutation.isPending} - onClick={() => deleteMutation.mutate({ j_ids: selected })} + onClick={() => void handleDeleteSelected()} > {deleteMutation.isPending ? "Deleting…" : "Delete selected"} diff --git a/frontend/src/features/library/IqdbCard.tsx b/frontend/src/features/library/IqdbCard.tsx index 05f6bb3..089578c 100644 --- a/frontend/src/features/library/IqdbCard.tsx +++ b/frontend/src/features/library/IqdbCard.tsx @@ -15,6 +15,7 @@ import { } from "@/lib/e621"; import type { MediaItem } from "@/lib/types"; import { useE621 } from "@/store/e621"; +import { toast } from "@/store/toasts"; const ratingPill: Record = { s: "bg-ctp-green text-ctp-crust", @@ -22,8 +23,6 @@ const ratingPill: Record = { e: "bg-ctp-red text-ctp-crust", }; -type Notice = { kind: "ok" | "warn" | "error"; text: string } | null; - export function IqdbCard({ item, canLink, @@ -36,14 +35,12 @@ export function IqdbCard({ const [busy, setBusy] = useState(false); const [candidates, setCandidates] = useState(null); const [selected, setSelected] = useState(null); - const [notice, setNotice] = useState(null); const isImage = item.kind === "image"; const configured = Boolean(credentials?.configured); async function search() { setBusy(true); - setNotice(null); setSelected(null); setCandidates(null); try { @@ -59,10 +56,10 @@ export function IqdbCard({ ); setCandidates(results); if (results.length === 0) { - setNotice({ kind: "warn", text: "No similar posts found on e621." }); + toast.info("No similar posts found on e621."); } } catch (error) { - setNotice({ kind: "error", text: e621ErrorMessage(error) }); + toast.error(e621ErrorMessage(error)); } finally { setBusy(false); } @@ -79,20 +76,15 @@ export function IqdbCard({ void queryClient.invalidateQueries({ queryKey: ["files"] }); void queryClient.invalidateQueries({ queryKey: ["tag-cloud"] }); setSelected(null); - setNotice( - data.md5_match - ? { - kind: "ok", - text: `Linked to post #${data.e621_post_id} — MD5 matches.`, - } - : { - kind: "warn", - text: `Linked to post #${data.e621_post_id}, but its MD5 differs from this file.`, - }, - ); + if (data.md5_match) { + toast.ok(`Linked to post #${data.e621_post_id} — MD5 matches.`); + } else { + toast.info( + `Linked to post #${data.e621_post_id}, but its MD5 differs from this file.`, + ); + } }, - onError: (error) => - setNotice({ kind: "error", text: errorMessage(error) }), + onError: (error) => toast.error(errorMessage(error)), }); return ( @@ -134,21 +126,6 @@ export function IqdbCard({

) : null} - {notice ? ( -

- {notice.text} -

- ) : null} - {candidates && candidates.length > 0 ? (
{candidates.map((candidate) => { diff --git a/frontend/src/features/library/LibraryDetail.tsx b/frontend/src/features/library/LibraryDetail.tsx index 4a1e2cd..0bc634a 100644 --- a/frontend/src/features/library/LibraryDetail.tsx +++ b/frontend/src/features/library/LibraryDetail.tsx @@ -21,6 +21,8 @@ import type { Rating, } from "@/lib/types"; import { useAuth } from "@/store/auth"; +import { confirmAction } from "@/store/confirm"; +import { toast } from "@/store/toasts"; import { useUi } from "@/store/ui"; const ratingChoices: { value: Rating; label: string; active: string }[] = [ @@ -77,10 +79,6 @@ const matchStatusMeta: Record< function MatchCard({ item, canEdit }: { item: MediaItem; canEdit: boolean }) { const queryClient = useQueryClient(); const [postId, setPostId] = useState(""); - const [notice, setNotice] = useState<{ - kind: "ok" | "warn" | "error"; - text: string; - } | null>(null); function refresh() { void queryClient.invalidateQueries({ queryKey: ["file", item.j_id] }); @@ -97,21 +95,14 @@ function MatchCard({ item, canEdit }: { item: MediaItem; canEdit: boolean }) { onSuccess: (data) => { refresh(); if (data.e621_match_status === "matched") { - setNotice({ kind: "ok", text: `Matched to post #${data.e621_post_id}.` }); + toast.ok(`Matched to post #${data.e621_post_id}.`); } else if (data.e621_match_status === "deleted") { - setNotice({ - kind: "error", - text: "The linked post no longer exists on e621.", - }); + toast.error("The linked post no longer exists on e621."); } else { - setNotice({ - kind: "warn", - text: "No e621 post with this file's MD5 was found.", - }); + toast.info("No e621 post with this file's MD5 was found."); } }, - onError: (error) => - setNotice({ kind: "error", text: errorMessage(error) }), + onError: (error) => toast.error(errorMessage(error)), }); const linkMutation = useMutation({ @@ -123,20 +114,15 @@ function MatchCard({ item, canEdit }: { item: MediaItem; canEdit: boolean }) { onSuccess: (data) => { refresh(); setPostId(""); - setNotice( - data.md5_match - ? { - kind: "ok", - text: `Linked to post #${data.e621_post_id} — MD5 matches.`, - } - : { - kind: "warn", - text: `Linked to post #${data.e621_post_id}, but its MD5 differs from this file.`, - }, - ); + if (data.md5_match) { + toast.ok(`Linked to post #${data.e621_post_id} — MD5 matches.`); + } else { + toast.info( + `Linked to post #${data.e621_post_id}, but its MD5 differs from this file.`, + ); + } }, - onError: (error) => - setNotice({ kind: "error", text: errorMessage(error) }), + onError: (error) => toast.error(errorMessage(error)), }); const unlinkMutation = useMutation({ @@ -144,10 +130,9 @@ function MatchCard({ item, canEdit }: { item: MediaItem; canEdit: boolean }) { api(`/api/files/${item.j_id}/unlink/`, { method: "POST" }), onSuccess: () => { refresh(); - setNotice({ kind: "ok", text: "e621 link removed." }); + toast.ok("e621 link removed."); }, - onError: (error) => - setNotice({ kind: "error", text: errorMessage(error) }), + onError: (error) => toast.error(errorMessage(error)), }); const meta = matchStatusMeta[item.e621_match_status]; @@ -215,21 +200,6 @@ function MatchCard({ item, canEdit }: { item: MediaItem; canEdit: boolean }) {
) : null} - - {notice ? ( -

- {notice.text} -

- ) : null}
); } @@ -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} - @@ -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? - - - - - ) : ( - - ) - ) : null} - {deleteMutation.isError ? ( -

- {errorMessage(deleteMutation.error)} -

+ ) : 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), +};