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.
This commit is contained in:
2026-09-17 22:19:54 -05:00
parent 1053e3ee55
commit e97c3b9da0
21 changed files with 430 additions and 287 deletions
+8 -43
View File
@@ -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<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(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 }) {
</div>
</Field>
{saveError ? (
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
{saveError}
</p>
) : null}
{saveMessage ? (
<p className="text-sm text-ctp-green">{saveMessage}</p>
) : null}
{testResult ? (
<p
className={cn(
"flex items-center gap-2 rounded-md border px-3 py-2 text-sm",
testResult.ok
? "border-ctp-green/30 bg-ctp-green/10 text-ctp-green"
: "border-ctp-red/30 bg-ctp-red/10 text-ctp-red",
)}
>
{testResult.ok ? (
<CheckCircle2 className="h-4 w-4 shrink-0" />
) : (
<XCircle className="h-4 w-4 shrink-0" />
)}
{testResult.message}
</p>
) : null}
<div className="flex items-center gap-3">
<Button onClick={() => void handleSave()} disabled={saving}>
{saving ? "Saving…" : "Save"}
@@ -6,6 +6,7 @@ import { Button, Spinner } from "@/components/ui";
import { api, errorMessage } from "@/lib/api";
import type { FollowedPool, Paginated } from "@/lib/types";
import { useAuth } from "@/store/auth";
import { toast } from "@/store/toasts";
export function PoolFollowButton({
poolId,
@@ -16,7 +17,6 @@ export function PoolFollowButton({
}) {
const user = useAuth((state) => state.user);
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
// What the button should show once the request and refetch settle.
const [target, setTarget] = useState<boolean | null>(null);
@@ -39,14 +39,13 @@ export function PoolFollowButton({
json: { pool_id: poolId },
}),
onSuccess: () => {
setError(null);
void queryClient.invalidateQueries({ queryKey: ["follows-pools"] });
void queryClient.invalidateQueries({ queryKey: ["follows-feed"] });
void queryClient.invalidateQueries({ queryKey: ["follows-cloud"] });
},
onError: (err) => {
setTarget(null);
setError(errorMessage(err));
toast.error(errorMessage(err));
},
});
@@ -76,7 +75,6 @@ export function PoolFollowButton({
)}
{followed ? "Following" : "Follow pool"}
</Button>
{error ? <p className="text-xs text-ctp-red">{error}</p> : null}
</div>
);
}
@@ -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<string | null>(null);
// What the chip should show once the request and refetch settle.
const [target, setTarget] = useState<boolean | null>(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",
)}
>
+34 -18
View File
@@ -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<string[]>([]);
const [selected, setSelected] = useState<string[]>([]);
const [status, setStatus] = useState<string | null>(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() {
</p>
</header>
{status ? (
<p className="rounded-md border border-ctp-surface1 bg-ctp-base px-3 py-2 text-xs text-ctp-subtext0">
{status}
</p>
) : null}
<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{storageQuery.isPending ? (
<Spinner className="h-5 w-5" />
@@ -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"}
</Button>
@@ -182,7 +199,6 @@ export default function DeletePage() {
<Button
variant="secondary"
onClick={() => {
setStatus(null);
setSelected([]);
setLoadedIds(parseJIds(jIdsInput));
}}
@@ -193,7 +209,7 @@ export default function DeletePage() {
<Button
variant="danger"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(selected)}
onClick={() => void handleDeleteSelected()}
>
{deleteMutation.isPending
? "Deleting…"
@@ -11,6 +11,8 @@ import type {
VisualGroupsResponse,
VisualMatchesResponse,
} from "@/lib/types";
import { confirmAction } from "@/store/confirm";
import { toast } from "@/store/toasts";
const ALGORITHMS = [
{ value: "ahash", label: "aHash" },
@@ -100,7 +102,6 @@ export default function DuplicatesPage() {
const [selected, setSelected] = useState<string[]>([]);
const [dismissed, setDismissed] = useState<string[]>([]);
const [similarTarget, setSimilarTarget] = useState("");
const [status, setStatus] = useState<string | null>(null);
const exactQuery = useQuery({
queryKey: ["duplicates-md5"],
@@ -130,7 +131,7 @@ export default function DuplicatesPage() {
method: "POST",
json: { j_id: jId, threshold, algorithms },
}),
onError: (error) => setStatus(errorMessage(error)),
onError: (error) => toast.error(errorMessage(error)),
});
const deleteMutation = useMutation({
@@ -138,18 +139,52 @@ export default function DuplicatesPage() {
api<DeleteResult>("/api/delete/", { method: "POST", json: payload }),
onSuccess: (result) => {
setSelected([]);
setStatus(
`Deleted ${result.deleted.length} entr${
result.deleted.length === 1 ? "y" : "ies"
}${result.errors.length > 0 ? ` · ${result.errors.length} failed` : ""}.`,
);
const noun = result.deleted.length === 1 ? "entry" : "entries";
toast.ok(`Deleted ${result.deleted.length} ${noun}.`);
if (result.errors.length > 0) {
toast.error(
`${result.errors.length} ${noun} could not be deleted: ` +
result.errors.map((entry) => entry.error).join(", "),
);
}
void queryClient.invalidateQueries({ queryKey: ["duplicates-md5"] });
void queryClient.invalidateQueries({ queryKey: ["duplicate-groups"] });
void queryClient.invalidateQueries({ queryKey: ["files"] });
},
onError: (error) => setStatus(errorMessage(error)),
onError: (error) => toast.error(errorMessage(error)),
});
async function handleDeleteCopy(locationId: number) {
const confirmed = await confirmAction({
title: "Delete this copy?",
description:
"The file at this path is removed. The item stays as long as another copy exists.",
confirmLabel: "Delete copy",
danger: true,
});
if (confirmed) deleteMutation.mutate({ location_ids: [locationId] });
}
async function handleDeleteItem(jId: string) {
const confirmed = await confirmAction({
title: `Delete ${jId}?`,
description: "The item and all of its copies are removed from the library.",
confirmLabel: "Delete",
danger: true,
});
if (confirmed) deleteMutation.mutate({ j_ids: [jId] });
}
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({ j_ids: selected });
}
function toggleSelected(jId: string) {
setSelected((current) =>
current.includes(jId)
@@ -190,12 +225,6 @@ export default function DuplicatesPage() {
</p>
</header>
{status ? (
<p className="rounded-md border border-ctp-surface1 bg-ctp-base px-3 py-2 text-xs text-ctp-subtext0">
{status}
</p>
) : null}
<section className="flex flex-col gap-3">
<header className="flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-ctp-subtext1">
@@ -252,9 +281,7 @@ export default function DuplicatesPage() {
</span>
<button
type="button"
onClick={() =>
deleteMutation.mutate({ location_ids: [location.id] })
}
onClick={() => void handleDeleteCopy(location.id)}
className="shrink-0 rounded-md px-1.5 py-0.5 font-mono text-[10px] text-ctp-red transition hover:bg-ctp-red/15"
>
delete copy
@@ -267,7 +294,7 @@ export default function DuplicatesPage() {
<Button
variant="danger"
className="px-2 py-1 text-xs"
onClick={() => deleteMutation.mutate({ j_ids: [group.j_id] })}
onClick={() => void handleDeleteItem(group.j_id)}
>
Delete item
</Button>
@@ -347,11 +374,6 @@ export default function DuplicatesPage() {
>
{similarMutation.isPending ? "Searching…" : "Search"}
</Button>
{similarMutation.isError ? (
<p className="text-xs text-ctp-red">
{errorMessage(similarMutation.error)}
</p>
) : null}
</div>
{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"}
</Button>
+11 -34
View File
@@ -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<string, string> = {
s: "bg-ctp-green text-ctp-crust",
@@ -22,8 +23,6 @@ const ratingPill: Record<string, string> = {
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<E621IqdbResult[] | null>(null);
const [selected, setSelected] = useState<E621IqdbResult | null>(null);
const [notice, setNotice] = useState<Notice>(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({
</p>
) : null}
{notice ? (
<p
className={cn(
"mt-2 text-xs",
notice.kind === "ok"
? "text-ctp-green"
: notice.kind === "warn"
? "text-ctp-yellow"
: "text-ctp-red",
)}
>
{notice.text}
</p>
) : null}
{candidates && candidates.length > 0 ? (
<div className="mt-3 grid grid-cols-3 gap-1.5">
{candidates.map((candidate) => {
+43 -88
View File
@@ -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<MediaItem>(`/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 }) {
</div>
</div>
) : null}
{notice ? (
<p
className={cn(
"mt-2 text-xs",
notice.kind === "ok"
? "text-ctp-green"
: notice.kind === "warn"
? "text-ctp-yellow"
: "text-ctp-red",
)}
>
{notice.text}
</p>
) : null}
</section>
);
}
@@ -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 ? (
<p className="text-sm text-ctp-red">{errorMessage(mutation.error)}</p>
) : mutation.isSuccess ? (
<p className="text-sm text-ctp-green">Saved.</p>
) : null}
<Button onClick={save} disabled={mutation.isPending}>
{mutation.isPending ? "Saving…" : "Save"}
</Button>
@@ -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 }) {
</Button>
) : null}
{canDelete ? (
confirmDelete ? (
<>
<span className="text-xs text-ctp-subtext0">
Delete this item and all its copies?
</span>
<Button
variant="danger"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
>
{deleteMutation.isPending ? "Deleting…" : "Confirm delete"}
</Button>
<Button
variant="ghost"
onClick={() => setConfirmDelete(false)}
disabled={deleteMutation.isPending}
>
Cancel
</Button>
</>
) : (
<Button
variant="danger"
onClick={() => setConfirmDelete(true)}
title="Delete this item from the library"
>
<Trash2 className="h-4 w-4" /> Delete
</Button>
)
) : null}
{deleteMutation.isError ? (
<p className="text-sm text-ctp-red">
{errorMessage(deleteMutation.error)}
</p>
<Button
variant="danger"
disabled={deleteMutation.isPending}
onClick={() => void handleDelete()}
title="Delete this item from the library"
>
<Trash2 className="h-4 w-4" />
{deleteMutation.isPending ? "Deleting…" : "Delete"}
</Button>
) : null}
</div>
}
+3 -5
View File
@@ -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.
</span>
</button>
{startMutation.isError ? (
<p className="px-2.5 py-1.5 text-xs text-ctp-red">
{errorMessage(startMutation.error)}
</p>
) : null}
</div>
) : null}
</div>
+3 -11
View File
@@ -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<string, string> = {
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<string | null>(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}
</p>
) : null}
{favoriteMutation.isError ? (
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
{e621ErrorMessage(favoriteMutation.error)}
</p>
) : null}
{downloadMutation.isError ? (
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
{errorMessage(downloadMutation.error)}
</p>
) : null}
</>
}
media={
+2 -5
View File
@@ -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() {
)}
</Button>
</form>
{blacklistMutation.isError ? (
<p className="text-xs text-ctp-red">
{e621ErrorMessage(blacklistMutation.error)}
</p>
) : null}
<p className="text-[11px] text-ctp-overlay0">
Saved to your e621 account.
</p>
@@ -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;
+5 -12
View File
@@ -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<SimilarityCheck | null>(null);
const [uploading, setUploading] = useState(false);
const [percent, setPercent] = useState(0);
const [error, setError] = useState<string | null>(null);
const [dragging, setDragging] = useState(false);
const [iqdb, setIqdb] = useState<E621IqdbResult[] | null>(null);
const [iqdbBusy, setIqdbBusy] = useState(false);
const [iqdbError, setIqdbError] = useState<string | null>(null);
const [selected, setSelected] = useState<E621IqdbResult | null>(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() {
</div>
) : null}
{error ? <p className="text-sm text-ctp-red">{error}</p> : null}
{check ? (
<div className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]">
<aside className="flex flex-col gap-4">
@@ -359,8 +354,6 @@ export default function SimilarPage() {
</Link>{" "}
to reverse-search this image on e621.
</p>
) : iqdbError ? (
<p className="mt-3 text-xs text-ctp-red">{iqdbError}</p>
) : iqdbBusy && !iqdb ? (
<div className="mt-3 flex justify-center py-4">
<Spinner className="h-4 w-4" />
+5 -12
View File
@@ -8,6 +8,7 @@ import { cn } from "@/lib/cn";
import { formatBytes } from "@/lib/format";
import type { StatJob, SystemStats } from "@/lib/types";
import { useAuth } from "@/store/auth";
import { toast } from "@/store/toasts";
const REFRESH_MS = 2000;
@@ -82,13 +83,11 @@ function JobRow({
showSummary,
onCancel,
cancelling,
cancelError,
}: {
job: StatJob;
showSummary?: boolean;
onCancel?: () => void;
cancelling?: boolean;
cancelError?: string | null;
}) {
const Icon = job.kind === "download" ? Download : ScanSearch;
return (
@@ -111,9 +110,6 @@ function JobRow({
</button>
) : null}
</div>
{cancelError ? (
<p className="pl-5 text-[11px] text-ctp-red">{cancelError}</p>
) : null}
{showSummary ? (
<p className="truncate pl-5 text-[11px] text-ctp-overlay0" title={job.summary ?? job.detail}>
{job.summary ?? job.detail}
@@ -159,10 +155,13 @@ export default function StatsPage() {
: `/api/matches/${job.id}/cancel/`,
{ method: "POST" },
),
onSuccess: () => {
onSuccess: (_data, job) => {
void queryClient.invalidateQueries({ queryKey: ["stats"] });
void queryClient.invalidateQueries({ queryKey: ["status"] });
toast.ok(`Cancelled ${job.label}.`);
},
onError: (error, job) =>
toast.error(`Could not cancel ${job.label}: ${errorMessage(error)}`),
});
const [autoScroll, setAutoScroll] = useState(true);
const logRef = useRef<HTMLDivElement>(null);
@@ -369,12 +368,6 @@ export default function StatsPage() {
cancelMutation.isPending &&
cancelMutation.variables?.id === job.id
}
cancelError={
cancelMutation.isError &&
cancelMutation.variables?.id === job.id
? errorMessage(cancelMutation.error)
: null
}
/>
))}
</ul>
+20 -9
View File
@@ -34,7 +34,9 @@ import type {
TempUpload,
} from "@/lib/types";
import { useAuth } from "@/store/auth";
import { confirmAction } from "@/store/confirm";
import { useE621 } from "@/store/e621";
import { toast } from "@/store/toasts";
type UploadStatus = "queued" | "uploading" | "done" | "error";
@@ -241,7 +243,7 @@ function MetadataModal({
void queryClient.invalidateQueries({ queryKey: ["files"] });
onClose();
} catch (err) {
setError(describeError(err));
toast.error(describeError(err));
setBusy(false);
}
}
@@ -284,11 +286,22 @@ function MetadataModal({
});
}
function discardUpload() {
void api(`/api/uploads/${temp.temp_id}/`, { method: "DELETE" }).then(() => {
void queryClient.invalidateQueries({ queryKey: ["uploads"] });
onClose();
async function discardUpload() {
const confirmed = await confirmAction({
title: "Discard this upload?",
description: `${temp.original_filename} is deleted from the staging folder; it never entered the library.`,
confirmLabel: "Discard",
danger: true,
});
if (!confirmed) return;
try {
await api(`/api/uploads/${temp.temp_id}/`, { method: "DELETE" });
void queryClient.invalidateQueries({ queryKey: ["uploads"] });
toast.ok("Upload discarded.");
onClose();
} catch (error) {
toast.error(errorMessage(error));
}
}
return (
@@ -416,7 +429,7 @@ function MetadataModal({
</p>
<button
type="button"
onClick={discardUpload}
onClick={() => void discardUpload()}
className="mt-2 rounded-md px-1.5 py-0.5 font-mono text-[10px] text-ctp-red transition hover:bg-ctp-red/15"
>
discard this upload
@@ -591,9 +604,7 @@ function MetadataModal({
)}
{error ? (
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
{error}
</p>
<p className="text-xs text-ctp-red">{error}</p>
) : null}
{busy ? (
<p className="flex items-center gap-2 text-xs text-ctp-overlay0">
+3 -15
View File
@@ -7,6 +7,7 @@ import { cn } from "@/lib/cn";
import { formatDate } from "@/lib/format";
import type { AdminUser, Paginated } from "@/lib/types";
import { useAuth } from "@/store/auth";
import { toast } from "@/store/toasts";
const ROLE_OPTIONS = [
{ value: "user", label: "User" },
@@ -17,8 +18,6 @@ const ROLE_OPTIONS = [
function UserRow({ profile }: { profile: AdminUser }) {
const queryClient = useQueryClient();
const [avatarJId, setAvatarJId] = useState(profile.avatar_j_id ?? "");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: (payload: Record<string, string | null>) =>
@@ -27,15 +26,11 @@ function UserRow({ profile }: { profile: AdminUser }) {
json: payload,
}),
onSuccess: (updated) => {
setMessage("Saved.");
setError(null);
setAvatarJId(updated.avatar_j_id ?? "");
void queryClient.invalidateQueries({ queryKey: ["users"] });
toast.ok(`Saved ${updated.username}.`);
},
onError: (err) => {
setError(errorMessage(err));
setMessage(null);
},
onError: (err) => toast.error(errorMessage(err)),
});
return (
@@ -105,13 +100,6 @@ function UserRow({ profile }: { profile: AdminUser }) {
Save avatar
</Button>
</div>
{message ? (
<p className="text-xs text-ctp-green lg:w-20">{message}</p>
) : null}
{error ? (
<p className="text-xs text-ctp-red lg:w-40">{error}</p>
) : null}
</div>
);
}