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:
+2
-1
@@ -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
|
- [x] 18+ entry screen: an age check gates the app before anything renders
|
||||||
(remembered per browser in `localStorage`)
|
(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)
|
- [ ] Mobile drawer polish for metadata panels (design spec §layout)
|
||||||
- [x] Profile pictures: staff Users page sets avatars from library J-IDs
|
- [x] Profile pictures: staff Users page sets avatars from library J-IDs
|
||||||
(self-service picker in Account still pending)
|
(self-service picker in Account still pending)
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { useEffect, useState } from "react";
|
|||||||
import { Link, NavLink, Outlet, useLocation, useNavigationType } from "react-router-dom";
|
import { Link, NavLink, Outlet, useLocation, useNavigationType } from "react-router-dom";
|
||||||
|
|
||||||
import { CommandPalette } from "@/components/CommandPalette";
|
import { CommandPalette } from "@/components/CommandPalette";
|
||||||
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||||
import { StatusFooter } from "@/components/StatusFooter";
|
import { StatusFooter } from "@/components/StatusFooter";
|
||||||
import { StatusPill } from "@/components/StatusPill";
|
import { StatusPill } from "@/components/StatusPill";
|
||||||
|
import { Toasts } from "@/components/Toasts";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import type { SystemStatus } from "@/lib/types";
|
import type { SystemStatus } from "@/lib/types";
|
||||||
@@ -182,6 +184,8 @@ export function AppShell() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
|
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
|
||||||
|
<ConfirmDialog />
|
||||||
|
<Toasts />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[70] flex items-center justify-center bg-ctp-crust/60 p-4 backdrop-blur-sm"
|
||||||
|
onClick={() => settle(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={request.title}
|
||||||
|
className="w-full max-w-sm rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4 shadow-[0_4px_12px_rgba(17,17,27,0.6)]"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 className="text-sm font-semibold text-ctp-text">{request.title}</h2>
|
||||||
|
{request.description ? (
|
||||||
|
<p className="mt-1.5 text-sm leading-relaxed text-ctp-subtext0">
|
||||||
|
{request.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<Button variant="secondary" autoFocus onClick={() => settle(false)}>
|
||||||
|
{request.cancelLabel ?? "Cancel"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={request.danger ? "danger" : "primary"}
|
||||||
|
onClick={() => settle(true)}
|
||||||
|
>
|
||||||
|
{request.confirmLabel ?? "Confirm"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<ToastKind, string> = {
|
||||||
|
ok: "border-ctp-green/40",
|
||||||
|
error: "border-ctp-red/40",
|
||||||
|
info: "border-ctp-blue/40",
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_ICONS: Record<ToastKind, LucideIcon> = {
|
||||||
|
ok: CheckCircle2,
|
||||||
|
error: AlertTriangle,
|
||||||
|
info: Info,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ICON_STYLES: Record<ToastKind, string> = {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
className="pointer-events-none fixed bottom-12 right-4 z-[60] flex w-[min(22rem,calc(100vw-2rem))] flex-col gap-2"
|
||||||
|
>
|
||||||
|
{toasts.map((item) => {
|
||||||
|
const Icon = KIND_ICONS[item.kind];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
role="status"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto flex items-start gap-2 rounded-lg border bg-ctp-surface0 p-3",
|
||||||
|
"shadow-[0_4px_12px_rgba(17,17,27,0.6)]",
|
||||||
|
KIND_STYLES[item.kind],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={cn("mt-0.5 h-4 w-4 shrink-0", ICON_STYLES[item.kind])} />
|
||||||
|
<p className="flex-1 text-sm leading-snug text-ctp-text">{item.text}</p>
|
||||||
|
{item.action ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
item.action?.onClick();
|
||||||
|
dismiss(item.id);
|
||||||
|
}}
|
||||||
|
className="shrink-0 rounded px-1.5 py-0.5 text-xs font-medium text-ctp-mauve transition hover:bg-ctp-surface1"
|
||||||
|
>
|
||||||
|
{item.action.label}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dismiss(item.id)}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
className="shrink-0 rounded p-0.5 text-ctp-overlay0 transition hover:bg-ctp-surface1 hover:text-ctp-text"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CheckCircle2, Eye, EyeOff, XCircle } from "lucide-react";
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { Button, Field, Spinner, inputClass } from "@/components/ui";
|
import { Button, Field, Spinner, inputClass } from "@/components/ui";
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "@/lib/e621";
|
} from "@/lib/e621";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
import { useE621 } from "@/store/e621";
|
import { useE621 } from "@/store/e621";
|
||||||
|
import { toast } from "@/store/toasts";
|
||||||
|
|
||||||
const BASE_URL_OPTIONS = [
|
const BASE_URL_OPTIONS = [
|
||||||
{ value: "https://e621.net", label: "e621.net — main site" },
|
{ 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 [customBase, setCustomBase] = useState(savedBaseIsKnown ? "" : savedBase);
|
||||||
const [showKey, setShowKey] = useState(false);
|
const [showKey, setShowKey] = useState(false);
|
||||||
const [saving, setSaving] = 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 [testing, setTesting] = useState(false);
|
||||||
const [testResult, setTestResult] = useState<{
|
|
||||||
ok: boolean;
|
|
||||||
message: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const baseUrl = (baseChoice === "custom" ? customBase.trim() : baseChoice).replace(
|
const baseUrl = (baseChoice === "custom" ? customBase.trim() : baseChoice).replace(
|
||||||
/\/+$/,
|
/\/+$/,
|
||||||
@@ -53,13 +48,11 @@ function AccountForm({ credentials }: { credentials: E621Credentials | null }) {
|
|||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveMessage(null);
|
|
||||||
setSaveError(null);
|
|
||||||
try {
|
try {
|
||||||
await save(values);
|
await save(values);
|
||||||
setSaveMessage("Credentials saved.");
|
toast.ok("e621 credentials saved.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSaveError(errorMessage(error));
|
toast.error(errorMessage(error));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -67,19 +60,17 @@ function AccountForm({ credentials }: { credentials: E621Credentials | null }) {
|
|||||||
|
|
||||||
async function handleTest() {
|
async function handleTest() {
|
||||||
setTesting(true);
|
setTesting(true);
|
||||||
setTestResult(null);
|
|
||||||
try {
|
try {
|
||||||
const user = await verifyE621Credentials({ ...values, configured: true });
|
const user = await verifyE621Credentials({ ...values, configured: true });
|
||||||
setTestResult({
|
toast.ok(
|
||||||
ok: true,
|
`Connected as ${user.name}${
|
||||||
message: `Connected as ${user.name}${
|
|
||||||
typeof user.favorite_count === "number"
|
typeof user.favorite_count === "number"
|
||||||
? ` — ${user.favorite_count} favorites`
|
? ` — ${user.favorite_count} favorites`
|
||||||
: ""
|
: ""
|
||||||
}.`,
|
}.`,
|
||||||
});
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setTestResult({ ok: false, message: e621ErrorMessage(error) });
|
toast.error(e621ErrorMessage(error));
|
||||||
} finally {
|
} finally {
|
||||||
setTesting(false);
|
setTesting(false);
|
||||||
}
|
}
|
||||||
@@ -167,32 +158,6 @@ function AccountForm({ credentials }: { credentials: E621Credentials | null }) {
|
|||||||
</div>
|
</div>
|
||||||
</Field>
|
</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">
|
<div className="flex items-center gap-3">
|
||||||
<Button onClick={() => void handleSave()} disabled={saving}>
|
<Button onClick={() => void handleSave()} disabled={saving}>
|
||||||
{saving ? "Saving…" : "Save"}
|
{saving ? "Saving…" : "Save"}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Button, Spinner } from "@/components/ui";
|
|||||||
import { api, errorMessage } from "@/lib/api";
|
import { api, errorMessage } from "@/lib/api";
|
||||||
import type { FollowedPool, Paginated } from "@/lib/types";
|
import type { FollowedPool, Paginated } from "@/lib/types";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
|
import { toast } from "@/store/toasts";
|
||||||
|
|
||||||
export function PoolFollowButton({
|
export function PoolFollowButton({
|
||||||
poolId,
|
poolId,
|
||||||
@@ -16,7 +17,6 @@ export function PoolFollowButton({
|
|||||||
}) {
|
}) {
|
||||||
const user = useAuth((state) => state.user);
|
const user = useAuth((state) => state.user);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
// What the button should show once the request and refetch settle.
|
// What the button should show once the request and refetch settle.
|
||||||
const [target, setTarget] = useState<boolean | null>(null);
|
const [target, setTarget] = useState<boolean | null>(null);
|
||||||
|
|
||||||
@@ -39,14 +39,13 @@ export function PoolFollowButton({
|
|||||||
json: { pool_id: poolId },
|
json: { pool_id: poolId },
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setError(null);
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ["follows-pools"] });
|
void queryClient.invalidateQueries({ queryKey: ["follows-pools"] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["follows-feed"] });
|
void queryClient.invalidateQueries({ queryKey: ["follows-feed"] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["follows-cloud"] });
|
void queryClient.invalidateQueries({ queryKey: ["follows-cloud"] });
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
setTarget(null);
|
setTarget(null);
|
||||||
setError(errorMessage(err));
|
toast.error(errorMessage(err));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,7 +75,6 @@ export function PoolFollowButton({
|
|||||||
)}
|
)}
|
||||||
{followed ? "Following" : "Follow pool"}
|
{followed ? "Following" : "Follow pool"}
|
||||||
</Button>
|
</Button>
|
||||||
{error ? <p className="text-xs text-ctp-red">{error}</p> : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import { api, errorMessage } from "@/lib/api";
|
|||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import type { FollowedTag, Paginated } from "@/lib/types";
|
import type { FollowedTag, Paginated } from "@/lib/types";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
|
import { toast } from "@/store/toasts";
|
||||||
|
|
||||||
export function TagFollowToggle({ tag }: { tag: string }) {
|
export function TagFollowToggle({ tag }: { tag: string }) {
|
||||||
const user = useAuth((state) => state.user);
|
const user = useAuth((state) => state.user);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
// What the chip should show once the request and refetch settle.
|
// What the chip should show once the request and refetch settle.
|
||||||
const [target, setTarget] = useState<boolean | null>(null);
|
const [target, setTarget] = useState<boolean | null>(null);
|
||||||
|
|
||||||
@@ -34,14 +34,13 @@ export function TagFollowToggle({ tag }: { tag: string }) {
|
|||||||
json: { tag: normalized },
|
json: { tag: normalized },
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setError(null);
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ["follows-tags"] });
|
void queryClient.invalidateQueries({ queryKey: ["follows-tags"] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["follows-feed"] });
|
void queryClient.invalidateQueries({ queryKey: ["follows-feed"] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["follows-cloud"] });
|
void queryClient.invalidateQueries({ queryKey: ["follows-cloud"] });
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
setTarget(null);
|
setTarget(null);
|
||||||
setError(errorMessage(err));
|
toast.error(errorMessage(err));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,11 +71,11 @@ export function TagFollowToggle({ tag }: { tag: string }) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}}
|
}}
|
||||||
disabled={busy || query.isPending}
|
disabled={busy || query.isPending}
|
||||||
title={error ?? (followed ? `Unfollow ${tag}` : `Follow ${tag}`)}
|
title={followed ? `Unfollow ${tag}` : `Follow ${tag}`}
|
||||||
aria-label={followed ? `Unfollow ${tag}` : `Follow ${tag}`}
|
aria-label={followed ? `Unfollow ${tag}` : `Follow ${tag}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"-mr-0.5 inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm transition",
|
"-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",
|
query.isPending && "opacity-40",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { api, errorMessage } from "@/lib/api";
|
|||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { formatBytes } from "@/lib/format";
|
import { formatBytes } from "@/lib/format";
|
||||||
import type { MediaItem, Paginated, StorageInfo } from "@/lib/types";
|
import type { MediaItem, Paginated, StorageInfo } from "@/lib/types";
|
||||||
|
import { confirmAction } from "@/store/confirm";
|
||||||
|
import { toast } from "@/store/toasts";
|
||||||
|
|
||||||
function parseJIds(text: string): string[] {
|
function parseJIds(text: string): string[] {
|
||||||
return text
|
return text
|
||||||
@@ -20,7 +22,6 @@ export default function DeletePage() {
|
|||||||
const [jIdsInput, setJIdsInput] = useState("");
|
const [jIdsInput, setJIdsInput] = useState("");
|
||||||
const [loadedIds, setLoadedIds] = useState<string[]>([]);
|
const [loadedIds, setLoadedIds] = useState<string[]>([]);
|
||||||
const [selected, setSelected] = useState<string[]>([]);
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
const [status, setStatus] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const storageQuery = useQuery({
|
const storageQuery = useQuery({
|
||||||
queryKey: ["storage"],
|
queryKey: ["storage"],
|
||||||
@@ -43,28 +44,50 @@ export default function DeletePage() {
|
|||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
setSelected([]);
|
setSelected([]);
|
||||||
setLoadedIds([]);
|
setLoadedIds([]);
|
||||||
setStatus(
|
toast.ok(`Deleted ${result.deleted.length} item(s).`);
|
||||||
`Deleted ${result.deleted.length} item(s)${
|
if (result.errors.length > 0) {
|
||||||
result.errors.length > 0 ? ` · ${result.errors.length} failed` : ""
|
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: ["storage"] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["duplicates-md5"] });
|
void queryClient.invalidateQueries({ queryKey: ["duplicates-md5"] });
|
||||||
},
|
},
|
||||||
onError: (error) => setStatus(errorMessage(error)),
|
onError: (error) => toast.error(errorMessage(error)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const tempMutation = useMutation({
|
const tempMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
api<{ removed: number }>("/api/temp/clear/", { method: "POST" }),
|
api<{ removed: number }>("/api/temp/clear/", { method: "POST" }),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
setStatus(`Removed ${result.removed} temp file(s).`);
|
toast.ok(`Removed ${result.removed} temp file(s).`);
|
||||||
void queryClient.invalidateQueries({ queryKey: ["storage"] });
|
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) {
|
function toggleSelected(jId: string) {
|
||||||
setSelected((current) =>
|
setSelected((current) =>
|
||||||
current.includes(jId)
|
current.includes(jId)
|
||||||
@@ -84,12 +107,6 @@ export default function DeletePage() {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</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">
|
<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{storageQuery.isPending ? (
|
{storageQuery.isPending ? (
|
||||||
<Spinner className="h-5 w-5" />
|
<Spinner className="h-5 w-5" />
|
||||||
@@ -157,7 +174,7 @@ export default function DeletePage() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="mt-2 px-2 py-1 text-xs"
|
className="mt-2 px-2 py-1 text-xs"
|
||||||
disabled={tempMutation.isPending || storage.temp.files === 0}
|
disabled={tempMutation.isPending || storage.temp.files === 0}
|
||||||
onClick={() => tempMutation.mutate()}
|
onClick={() => void handleClearTemp()}
|
||||||
>
|
>
|
||||||
{tempMutation.isPending ? "Clearing…" : "Clear temp folder"}
|
{tempMutation.isPending ? "Clearing…" : "Clear temp folder"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -182,7 +199,6 @@ export default function DeletePage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStatus(null);
|
|
||||||
setSelected([]);
|
setSelected([]);
|
||||||
setLoadedIds(parseJIds(jIdsInput));
|
setLoadedIds(parseJIds(jIdsInput));
|
||||||
}}
|
}}
|
||||||
@@ -193,7 +209,7 @@ export default function DeletePage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
onClick={() => deleteMutation.mutate(selected)}
|
onClick={() => void handleDeleteSelected()}
|
||||||
>
|
>
|
||||||
{deleteMutation.isPending
|
{deleteMutation.isPending
|
||||||
? "Deleting…"
|
? "Deleting…"
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type {
|
|||||||
VisualGroupsResponse,
|
VisualGroupsResponse,
|
||||||
VisualMatchesResponse,
|
VisualMatchesResponse,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
import { confirmAction } from "@/store/confirm";
|
||||||
|
import { toast } from "@/store/toasts";
|
||||||
|
|
||||||
const ALGORITHMS = [
|
const ALGORITHMS = [
|
||||||
{ value: "ahash", label: "aHash" },
|
{ value: "ahash", label: "aHash" },
|
||||||
@@ -100,7 +102,6 @@ export default function DuplicatesPage() {
|
|||||||
const [selected, setSelected] = useState<string[]>([]);
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
const [dismissed, setDismissed] = useState<string[]>([]);
|
const [dismissed, setDismissed] = useState<string[]>([]);
|
||||||
const [similarTarget, setSimilarTarget] = useState("");
|
const [similarTarget, setSimilarTarget] = useState("");
|
||||||
const [status, setStatus] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const exactQuery = useQuery({
|
const exactQuery = useQuery({
|
||||||
queryKey: ["duplicates-md5"],
|
queryKey: ["duplicates-md5"],
|
||||||
@@ -130,7 +131,7 @@ export default function DuplicatesPage() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
json: { j_id: jId, threshold, algorithms },
|
json: { j_id: jId, threshold, algorithms },
|
||||||
}),
|
}),
|
||||||
onError: (error) => setStatus(errorMessage(error)),
|
onError: (error) => toast.error(errorMessage(error)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
@@ -138,18 +139,52 @@ export default function DuplicatesPage() {
|
|||||||
api<DeleteResult>("/api/delete/", { method: "POST", json: payload }),
|
api<DeleteResult>("/api/delete/", { method: "POST", json: payload }),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
setSelected([]);
|
setSelected([]);
|
||||||
setStatus(
|
const noun = result.deleted.length === 1 ? "entry" : "entries";
|
||||||
`Deleted ${result.deleted.length} entr${
|
toast.ok(`Deleted ${result.deleted.length} ${noun}.`);
|
||||||
result.deleted.length === 1 ? "y" : "ies"
|
if (result.errors.length > 0) {
|
||||||
}${result.errors.length > 0 ? ` · ${result.errors.length} failed` : ""}.`,
|
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: ["duplicates-md5"] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["duplicate-groups"] });
|
void queryClient.invalidateQueries({ queryKey: ["duplicate-groups"] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
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) {
|
function toggleSelected(jId: string) {
|
||||||
setSelected((current) =>
|
setSelected((current) =>
|
||||||
current.includes(jId)
|
current.includes(jId)
|
||||||
@@ -190,12 +225,6 @@ export default function DuplicatesPage() {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</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">
|
<section className="flex flex-col gap-3">
|
||||||
<header className="flex items-center justify-between gap-2">
|
<header className="flex items-center justify-between gap-2">
|
||||||
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
||||||
@@ -252,9 +281,7 @@ export default function DuplicatesPage() {
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => void handleDeleteCopy(location.id)}
|
||||||
deleteMutation.mutate({ location_ids: [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"
|
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
|
delete copy
|
||||||
@@ -267,7 +294,7 @@ export default function DuplicatesPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
className="px-2 py-1 text-xs"
|
className="px-2 py-1 text-xs"
|
||||||
onClick={() => deleteMutation.mutate({ j_ids: [group.j_id] })}
|
onClick={() => void handleDeleteItem(group.j_id)}
|
||||||
>
|
>
|
||||||
Delete item
|
Delete item
|
||||||
</Button>
|
</Button>
|
||||||
@@ -347,11 +374,6 @@ export default function DuplicatesPage() {
|
|||||||
>
|
>
|
||||||
{similarMutation.isPending ? "Searching…" : "Search"}
|
{similarMutation.isPending ? "Searching…" : "Search"}
|
||||||
</Button>
|
</Button>
|
||||||
{similarMutation.isError ? (
|
|
||||||
<p className="text-xs text-ctp-red">
|
|
||||||
{errorMessage(similarMutation.error)}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{similarMutation.data ? (
|
{similarMutation.data ? (
|
||||||
@@ -460,7 +482,7 @@ export default function DuplicatesPage() {
|
|||||||
variant="danger"
|
variant="danger"
|
||||||
className="px-2 py-1 text-xs"
|
className="px-2 py-1 text-xs"
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
onClick={() => deleteMutation.mutate({ j_ids: selected })}
|
onClick={() => void handleDeleteSelected()}
|
||||||
>
|
>
|
||||||
{deleteMutation.isPending ? "Deleting…" : "Delete selected"}
|
{deleteMutation.isPending ? "Deleting…" : "Delete selected"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "@/lib/e621";
|
} from "@/lib/e621";
|
||||||
import type { MediaItem } from "@/lib/types";
|
import type { MediaItem } from "@/lib/types";
|
||||||
import { useE621 } from "@/store/e621";
|
import { useE621 } from "@/store/e621";
|
||||||
|
import { toast } from "@/store/toasts";
|
||||||
|
|
||||||
const ratingPill: Record<string, string> = {
|
const ratingPill: Record<string, string> = {
|
||||||
s: "bg-ctp-green text-ctp-crust",
|
s: "bg-ctp-green text-ctp-crust",
|
||||||
@@ -22,8 +23,6 @@ const ratingPill: Record<string, string> = {
|
|||||||
e: "bg-ctp-red text-ctp-crust",
|
e: "bg-ctp-red text-ctp-crust",
|
||||||
};
|
};
|
||||||
|
|
||||||
type Notice = { kind: "ok" | "warn" | "error"; text: string } | null;
|
|
||||||
|
|
||||||
export function IqdbCard({
|
export function IqdbCard({
|
||||||
item,
|
item,
|
||||||
canLink,
|
canLink,
|
||||||
@@ -36,14 +35,12 @@ export function IqdbCard({
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [candidates, setCandidates] = useState<E621IqdbResult[] | null>(null);
|
const [candidates, setCandidates] = useState<E621IqdbResult[] | null>(null);
|
||||||
const [selected, setSelected] = useState<E621IqdbResult | null>(null);
|
const [selected, setSelected] = useState<E621IqdbResult | null>(null);
|
||||||
const [notice, setNotice] = useState<Notice>(null);
|
|
||||||
|
|
||||||
const isImage = item.kind === "image";
|
const isImage = item.kind === "image";
|
||||||
const configured = Boolean(credentials?.configured);
|
const configured = Boolean(credentials?.configured);
|
||||||
|
|
||||||
async function search() {
|
async function search() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setNotice(null);
|
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setCandidates(null);
|
setCandidates(null);
|
||||||
try {
|
try {
|
||||||
@@ -59,10 +56,10 @@ export function IqdbCard({
|
|||||||
);
|
);
|
||||||
setCandidates(results);
|
setCandidates(results);
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
setNotice({ kind: "warn", text: "No similar posts found on e621." });
|
toast.info("No similar posts found on e621.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setNotice({ kind: "error", text: e621ErrorMessage(error) });
|
toast.error(e621ErrorMessage(error));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -79,20 +76,15 @@ export function IqdbCard({
|
|||||||
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["tag-cloud"] });
|
void queryClient.invalidateQueries({ queryKey: ["tag-cloud"] });
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setNotice(
|
if (data.md5_match) {
|
||||||
data.md5_match
|
toast.ok(`Linked to post #${data.e621_post_id} — MD5 matches.`);
|
||||||
? {
|
} else {
|
||||||
kind: "ok",
|
toast.info(
|
||||||
text: `Linked to post #${data.e621_post_id} — MD5 matches.`,
|
`Linked to post #${data.e621_post_id}, but its MD5 differs from this file.`,
|
||||||
}
|
|
||||||
: {
|
|
||||||
kind: "warn",
|
|
||||||
text: `Linked to post #${data.e621_post_id}, but its MD5 differs from this file.`,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (error) =>
|
onError: (error) => toast.error(errorMessage(error)),
|
||||||
setNotice({ kind: "error", text: errorMessage(error) }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -134,21 +126,6 @@ export function IqdbCard({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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 ? (
|
{candidates && candidates.length > 0 ? (
|
||||||
<div className="mt-3 grid grid-cols-3 gap-1.5">
|
<div className="mt-3 grid grid-cols-3 gap-1.5">
|
||||||
{candidates.map((candidate) => {
|
{candidates.map((candidate) => {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import type {
|
|||||||
Rating,
|
Rating,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
|
import { confirmAction } from "@/store/confirm";
|
||||||
|
import { toast } from "@/store/toasts";
|
||||||
import { useUi } from "@/store/ui";
|
import { useUi } from "@/store/ui";
|
||||||
|
|
||||||
const ratingChoices: { value: Rating; label: string; active: string }[] = [
|
const ratingChoices: { value: Rating; label: string; active: string }[] = [
|
||||||
@@ -77,10 +79,6 @@ const matchStatusMeta: Record<
|
|||||||
function MatchCard({ item, canEdit }: { item: MediaItem; canEdit: boolean }) {
|
function MatchCard({ item, canEdit }: { item: MediaItem; canEdit: boolean }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [postId, setPostId] = useState("");
|
const [postId, setPostId] = useState("");
|
||||||
const [notice, setNotice] = useState<{
|
|
||||||
kind: "ok" | "warn" | "error";
|
|
||||||
text: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["file", item.j_id] });
|
void queryClient.invalidateQueries({ queryKey: ["file", item.j_id] });
|
||||||
@@ -97,21 +95,14 @@ function MatchCard({ item, canEdit }: { item: MediaItem; canEdit: boolean }) {
|
|||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
refresh();
|
refresh();
|
||||||
if (data.e621_match_status === "matched") {
|
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") {
|
} else if (data.e621_match_status === "deleted") {
|
||||||
setNotice({
|
toast.error("The linked post no longer exists on e621.");
|
||||||
kind: "error",
|
|
||||||
text: "The linked post no longer exists on e621.",
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
setNotice({
|
toast.info("No e621 post with this file's MD5 was found.");
|
||||||
kind: "warn",
|
|
||||||
text: "No e621 post with this file's MD5 was found.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) =>
|
onError: (error) => toast.error(errorMessage(error)),
|
||||||
setNotice({ kind: "error", text: errorMessage(error) }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const linkMutation = useMutation({
|
const linkMutation = useMutation({
|
||||||
@@ -123,20 +114,15 @@ function MatchCard({ item, canEdit }: { item: MediaItem; canEdit: boolean }) {
|
|||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
refresh();
|
refresh();
|
||||||
setPostId("");
|
setPostId("");
|
||||||
setNotice(
|
if (data.md5_match) {
|
||||||
data.md5_match
|
toast.ok(`Linked to post #${data.e621_post_id} — MD5 matches.`);
|
||||||
? {
|
} else {
|
||||||
kind: "ok",
|
toast.info(
|
||||||
text: `Linked to post #${data.e621_post_id} — MD5 matches.`,
|
`Linked to post #${data.e621_post_id}, but its MD5 differs from this file.`,
|
||||||
}
|
|
||||||
: {
|
|
||||||
kind: "warn",
|
|
||||||
text: `Linked to post #${data.e621_post_id}, but its MD5 differs from this file.`,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (error) =>
|
onError: (error) => toast.error(errorMessage(error)),
|
||||||
setNotice({ kind: "error", text: errorMessage(error) }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const unlinkMutation = useMutation({
|
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" }),
|
api<MediaItem>(`/api/files/${item.j_id}/unlink/`, { method: "POST" }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
refresh();
|
refresh();
|
||||||
setNotice({ kind: "ok", text: "e621 link removed." });
|
toast.ok("e621 link removed.");
|
||||||
},
|
},
|
||||||
onError: (error) =>
|
onError: (error) => toast.error(errorMessage(error)),
|
||||||
setNotice({ kind: "error", text: errorMessage(error) }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const meta = matchStatusMeta[item.e621_match_status];
|
const meta = matchStatusMeta[item.e621_match_status];
|
||||||
@@ -215,21 +200,6 @@ function MatchCard({ item, canEdit }: { item: MediaItem; canEdit: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -392,7 +362,9 @@ function CustomDataCard({ item, canEdit }: { item: MediaItem; canEdit: boolean }
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["file", item.j_id] });
|
void queryClient.invalidateQueries({ queryKey: ["file", item.j_id] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
||||||
|
toast.ok("Custom data saved.");
|
||||||
},
|
},
|
||||||
|
onError: (error) => toast.error(errorMessage(error)),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!canEdit) {
|
if (!canEdit) {
|
||||||
@@ -476,12 +448,6 @@ function CustomDataCard({ item, canEdit }: { item: MediaItem; canEdit: boolean }
|
|||||||
onChange={(event) => setNotes(event.target.value)}
|
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}>
|
<Button onClick={save} disabled={mutation.isPending}>
|
||||||
{mutation.isPending ? "Saving…" : "Save"}
|
{mutation.isPending ? "Saving…" : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -495,7 +461,6 @@ export function LibraryDetail({ jId }: { jId: string }) {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const ordering = useUi((state) => state.ordering);
|
const ordering = useUi((state) => state.ordering);
|
||||||
const user = useAuth((state) => state.user);
|
const user = useAuth((state) => state.user);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
||||||
const [optimizeOpen, setOptimizeOpen] = useState(false);
|
const [optimizeOpen, setOptimizeOpen] = useState(false);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
@@ -522,14 +487,29 @@ export function LibraryDetail({ jId }: { jId: string }) {
|
|||||||
"/api/delete/",
|
"/api/delete/",
|
||||||
{ method: "POST", json: { j_ids: [jId] } },
|
{ method: "POST", json: { j_ids: [jId] } },
|
||||||
),
|
),
|
||||||
onSuccess: () => {
|
onSuccess: (data) => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["duplicates-md5"] });
|
void queryClient.invalidateQueries({ queryKey: ["duplicates-md5"] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["storage"] });
|
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("/");
|
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(() => {
|
useEffect(() => {
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
||||||
@@ -615,40 +595,15 @@ export function LibraryDetail({ jId }: { jId: string }) {
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{canDelete ? (
|
{canDelete ? (
|
||||||
confirmDelete ? (
|
|
||||||
<>
|
|
||||||
<span className="text-xs text-ctp-subtext0">
|
|
||||||
Delete this item and all its copies?
|
|
||||||
</span>
|
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
onClick={() => deleteMutation.mutate()}
|
onClick={() => void handleDelete()}
|
||||||
>
|
|
||||||
{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"
|
title="Delete this item from the library"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" /> Delete
|
<Trash2 className="h-4 w-4" />
|
||||||
|
{deleteMutation.isPending ? "Deleting…" : "Delete"}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
{deleteMutation.isError ? (
|
|
||||||
<p className="text-sm text-ctp-red">
|
|
||||||
{errorMessage(deleteMutation.error)}
|
|
||||||
</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { Button, Spinner } from "@/components/ui";
|
import { Button, Spinner } from "@/components/ui";
|
||||||
import { api, errorMessage } from "@/lib/api";
|
import { api, errorMessage } from "@/lib/api";
|
||||||
import type { MatchTask } from "@/lib/types";
|
import type { MatchTask } from "@/lib/types";
|
||||||
|
import { toast } from "@/store/toasts";
|
||||||
|
|
||||||
type Scope = "missing" | "all";
|
type Scope = "missing" | "all";
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ export function MatchPanel() {
|
|||||||
setTaskId(data.task_id);
|
setTaskId(data.task_id);
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
},
|
},
|
||||||
|
onError: (error) => toast.error(errorMessage(error)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const cancelMutation = useMutation({
|
const cancelMutation = useMutation({
|
||||||
@@ -57,6 +59,7 @@ export function MatchPanel() {
|
|||||||
api<{ success: boolean }>(`/api/matches/${taskId}/cancel/`, {
|
api<{ success: boolean }>(`/api/matches/${taskId}/cancel/`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
}),
|
}),
|
||||||
|
onError: (error) => toast.error(errorMessage(error)),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (running && task) {
|
if (running && task) {
|
||||||
@@ -146,11 +149,6 @@ export function MatchPanel() {
|
|||||||
Refreshes matched metadata and flags deleted posts too.
|
Refreshes matched metadata and flags deleted posts too.
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{startMutation.isError ? (
|
|
||||||
<p className="px-2.5 py-1.5 text-xs text-ctp-red">
|
|
||||||
{errorMessage(startMutation.error)}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { formatBytes, formatDate } from "@/lib/format";
|
|||||||
import type { DownloadTask, Paginated } from "@/lib/types";
|
import type { DownloadTask, Paginated } from "@/lib/types";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
import { useE621 } from "@/store/e621";
|
import { useE621 } from "@/store/e621";
|
||||||
|
import { toast } from "@/store/toasts";
|
||||||
|
|
||||||
const ratingText: Record<string, string> = {
|
const ratingText: Record<string, string> = {
|
||||||
s: "text-ctp-green",
|
s: "text-ctp-green",
|
||||||
@@ -115,6 +116,7 @@ export function OnlineDetail({ postId }: { postId: number }) {
|
|||||||
queryKey: ["e621", "post", postId],
|
queryKey: ["e621", "post", postId],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
onError: (error) => toast.error(e621ErrorMessage(error)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [downloadTaskId, setDownloadTaskId] = useState<string | null>(null);
|
const [downloadTaskId, setDownloadTaskId] = useState<string | null>(null);
|
||||||
@@ -166,6 +168,7 @@ export function OnlineDetail({ postId }: { postId: number }) {
|
|||||||
setDownloadTaskId(task.task_id);
|
setDownloadTaskId(task.task_id);
|
||||||
void queryClient.invalidateQueries({ queryKey: ["status"] });
|
void queryClient.invalidateQueries({ queryKey: ["status"] });
|
||||||
},
|
},
|
||||||
|
onError: (error) => toast.error(errorMessage(error)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const taskQuery = useQuery({
|
const taskQuery = useQuery({
|
||||||
@@ -400,17 +403,6 @@ export function OnlineDetail({ postId }: { postId: number }) {
|
|||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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={
|
media={
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
type E621Post,
|
type E621Post,
|
||||||
} from "@/lib/e621";
|
} from "@/lib/e621";
|
||||||
import { useE621 } from "@/store/e621";
|
import { useE621 } from "@/store/e621";
|
||||||
|
import { toast } from "@/store/toasts";
|
||||||
|
|
||||||
const PAGE_SIZE = 48;
|
const PAGE_SIZE = 48;
|
||||||
const EMPTY_POSTS: E621Post[] = [];
|
const EMPTY_POSTS: E621Post[] = [];
|
||||||
@@ -48,6 +49,7 @@ export default function OnlinePage() {
|
|||||||
void queryClient.invalidateQueries({ queryKey: ["follows-cloud"] });
|
void queryClient.invalidateQueries({ queryKey: ["follows-cloud"] });
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
onError: (error) => toast.error(e621ErrorMessage(error)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const blacklistEntries = useMemo(
|
const blacklistEntries = useMemo(
|
||||||
@@ -270,11 +272,6 @@ export default function OnlinePage() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
{blacklistMutation.isError ? (
|
|
||||||
<p className="text-xs text-ctp-red">
|
|
||||||
{e621ErrorMessage(blacklistMutation.error)}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<p className="text-[11px] text-ctp-overlay0">
|
<p className="text-[11px] text-ctp-overlay0">
|
||||||
Saved to your e621 account.
|
Saved to your e621 account.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
type OptimizeOptions,
|
type OptimizeOptions,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { useOptimizer } from "./useOptimizer";
|
import { useOptimizer } from "./useOptimizer";
|
||||||
|
import { toast } from "@/store/toasts";
|
||||||
|
|
||||||
const DIMENSION_CHOICES: { value: number | null; label: string }[] = [
|
const DIMENSION_CHOICES: { value: number | null; label: string }[] = [
|
||||||
{ value: null, label: "Keep original" },
|
{ value: null, label: "Keep original" },
|
||||||
@@ -248,12 +249,17 @@ export function OptimizeModal({
|
|||||||
if (result?.storageKey) void deleteStoredFile(result.storageKey);
|
if (result?.storageKey) void deleteStoredFile(result.storageKey);
|
||||||
setApplied(true);
|
setApplied(true);
|
||||||
setApplyError(null);
|
setApplyError(null);
|
||||||
|
toast.ok(`${item.j_id} optimized.`);
|
||||||
void queryClient.invalidateQueries({ queryKey: ["file", item.j_id] });
|
void queryClient.invalidateQueries({ queryKey: ["file", item.j_id] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["storage"] });
|
void queryClient.invalidateQueries({ queryKey: ["storage"] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["duplicates-md5"] });
|
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;
|
const originalSize = item.size;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import { formatBytes, formatDate } from "@/lib/format";
|
import { formatBytes, formatDate } from "@/lib/format";
|
||||||
import type { SimilarityCheck } from "@/lib/types";
|
import type { SimilarityCheck } from "@/lib/types";
|
||||||
import { useE621 } from "@/store/e621";
|
import { useE621 } from "@/store/e621";
|
||||||
|
import { toast } from "@/store/toasts";
|
||||||
|
|
||||||
const VIDEO_RE = /\.(mp4|webm)$/i;
|
const VIDEO_RE = /\.(mp4|webm)$/i;
|
||||||
|
|
||||||
@@ -39,12 +40,10 @@ export default function SimilarPage() {
|
|||||||
const [check, setCheck] = useState<SimilarityCheck | null>(null);
|
const [check, setCheck] = useState<SimilarityCheck | null>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [percent, setPercent] = useState(0);
|
const [percent, setPercent] = useState(0);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
|
|
||||||
const [iqdb, setIqdb] = useState<E621IqdbResult[] | null>(null);
|
const [iqdb, setIqdb] = useState<E621IqdbResult[] | null>(null);
|
||||||
const [iqdbBusy, setIqdbBusy] = useState(false);
|
const [iqdbBusy, setIqdbBusy] = useState(false);
|
||||||
const [iqdbError, setIqdbError] = useState<string | null>(null);
|
|
||||||
const [selected, setSelected] = useState<E621IqdbResult | null>(null);
|
const [selected, setSelected] = useState<E621IqdbResult | null>(null);
|
||||||
|
|
||||||
const configured = Boolean(credentials?.configured);
|
const configured = Boolean(credentials?.configured);
|
||||||
@@ -57,10 +56,10 @@ export default function SimilarPage() {
|
|||||||
setFile(null);
|
setFile(null);
|
||||||
setIqdb(null);
|
setIqdb(null);
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setError(null);
|
|
||||||
setIqdbError(null);
|
|
||||||
if (inputRef.current) inputRef.current.value = "";
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
|
toast.ok("Temporary copy deleted.");
|
||||||
},
|
},
|
||||||
|
onError: (error) => toast.error(errorMessage(error)),
|
||||||
});
|
});
|
||||||
|
|
||||||
async function startCheck(selectedFile: File) {
|
async function startCheck(selectedFile: File) {
|
||||||
@@ -68,8 +67,6 @@ export default function SimilarPage() {
|
|||||||
setCheck(null);
|
setCheck(null);
|
||||||
setIqdb(null);
|
setIqdb(null);
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setError(null);
|
|
||||||
setIqdbError(null);
|
|
||||||
setPercent(0);
|
setPercent(0);
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
@@ -82,7 +79,7 @@ export default function SimilarPage() {
|
|||||||
setCheck(data);
|
setCheck(data);
|
||||||
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorMessage(err));
|
toast.error(errorMessage(err));
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
@@ -98,7 +95,7 @@ export default function SimilarPage() {
|
|||||||
);
|
);
|
||||||
setIqdb(results);
|
setIqdb(results);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setIqdbError(e621ErrorMessage(err));
|
toast.error(e621ErrorMessage(err));
|
||||||
} finally {
|
} finally {
|
||||||
setIqdbBusy(false);
|
setIqdbBusy(false);
|
||||||
}
|
}
|
||||||
@@ -183,8 +180,6 @@ export default function SimilarPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{error ? <p className="text-sm text-ctp-red">{error}</p> : null}
|
|
||||||
|
|
||||||
{check ? (
|
{check ? (
|
||||||
<div className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]">
|
<div className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||||
<aside className="flex flex-col gap-4">
|
<aside className="flex flex-col gap-4">
|
||||||
@@ -359,8 +354,6 @@ export default function SimilarPage() {
|
|||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
to reverse-search this image on e621.
|
to reverse-search this image on e621.
|
||||||
</p>
|
</p>
|
||||||
) : iqdbError ? (
|
|
||||||
<p className="mt-3 text-xs text-ctp-red">{iqdbError}</p>
|
|
||||||
) : iqdbBusy && !iqdb ? (
|
) : iqdbBusy && !iqdb ? (
|
||||||
<div className="mt-3 flex justify-center py-4">
|
<div className="mt-3 flex justify-center py-4">
|
||||||
<Spinner className="h-4 w-4" />
|
<Spinner className="h-4 w-4" />
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { cn } from "@/lib/cn";
|
|||||||
import { formatBytes } from "@/lib/format";
|
import { formatBytes } from "@/lib/format";
|
||||||
import type { StatJob, SystemStats } from "@/lib/types";
|
import type { StatJob, SystemStats } from "@/lib/types";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
|
import { toast } from "@/store/toasts";
|
||||||
|
|
||||||
const REFRESH_MS = 2000;
|
const REFRESH_MS = 2000;
|
||||||
|
|
||||||
@@ -82,13 +83,11 @@ function JobRow({
|
|||||||
showSummary,
|
showSummary,
|
||||||
onCancel,
|
onCancel,
|
||||||
cancelling,
|
cancelling,
|
||||||
cancelError,
|
|
||||||
}: {
|
}: {
|
||||||
job: StatJob;
|
job: StatJob;
|
||||||
showSummary?: boolean;
|
showSummary?: boolean;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
cancelling?: boolean;
|
cancelling?: boolean;
|
||||||
cancelError?: string | null;
|
|
||||||
}) {
|
}) {
|
||||||
const Icon = job.kind === "download" ? Download : ScanSearch;
|
const Icon = job.kind === "download" ? Download : ScanSearch;
|
||||||
return (
|
return (
|
||||||
@@ -111,9 +110,6 @@ function JobRow({
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{cancelError ? (
|
|
||||||
<p className="pl-5 text-[11px] text-ctp-red">{cancelError}</p>
|
|
||||||
) : null}
|
|
||||||
{showSummary ? (
|
{showSummary ? (
|
||||||
<p className="truncate pl-5 text-[11px] text-ctp-overlay0" title={job.summary ?? job.detail}>
|
<p className="truncate pl-5 text-[11px] text-ctp-overlay0" title={job.summary ?? job.detail}>
|
||||||
{job.summary ?? job.detail}
|
{job.summary ?? job.detail}
|
||||||
@@ -159,10 +155,13 @@ export default function StatsPage() {
|
|||||||
: `/api/matches/${job.id}/cancel/`,
|
: `/api/matches/${job.id}/cancel/`,
|
||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
),
|
),
|
||||||
onSuccess: () => {
|
onSuccess: (_data, job) => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["stats"] });
|
void queryClient.invalidateQueries({ queryKey: ["stats"] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["status"] });
|
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 [autoScroll, setAutoScroll] = useState(true);
|
||||||
const logRef = useRef<HTMLDivElement>(null);
|
const logRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -369,12 +368,6 @@ export default function StatsPage() {
|
|||||||
cancelMutation.isPending &&
|
cancelMutation.isPending &&
|
||||||
cancelMutation.variables?.id === job.id
|
cancelMutation.variables?.id === job.id
|
||||||
}
|
}
|
||||||
cancelError={
|
|
||||||
cancelMutation.isError &&
|
|
||||||
cancelMutation.variables?.id === job.id
|
|
||||||
? errorMessage(cancelMutation.error)
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ import type {
|
|||||||
TempUpload,
|
TempUpload,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
|
import { confirmAction } from "@/store/confirm";
|
||||||
import { useE621 } from "@/store/e621";
|
import { useE621 } from "@/store/e621";
|
||||||
|
import { toast } from "@/store/toasts";
|
||||||
|
|
||||||
type UploadStatus = "queued" | "uploading" | "done" | "error";
|
type UploadStatus = "queued" | "uploading" | "done" | "error";
|
||||||
|
|
||||||
@@ -241,7 +243,7 @@ function MetadataModal({
|
|||||||
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(describeError(err));
|
toast.error(describeError(err));
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,11 +286,22 @@ function MetadataModal({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function discardUpload() {
|
async function discardUpload() {
|
||||||
void api(`/api/uploads/${temp.temp_id}/`, { method: "DELETE" }).then(() => {
|
const confirmed = await confirmAction({
|
||||||
void queryClient.invalidateQueries({ queryKey: ["uploads"] });
|
title: "Discard this upload?",
|
||||||
onClose();
|
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 (
|
return (
|
||||||
@@ -416,7 +429,7 @@ function MetadataModal({
|
|||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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
|
discard this upload
|
||||||
@@ -591,9 +604,7 @@ function MetadataModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
|
<p className="text-xs text-ctp-red">{error}</p>
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
{busy ? (
|
{busy ? (
|
||||||
<p className="flex items-center gap-2 text-xs text-ctp-overlay0">
|
<p className="flex items-center gap-2 text-xs text-ctp-overlay0">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { cn } from "@/lib/cn";
|
|||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
import type { AdminUser, Paginated } from "@/lib/types";
|
import type { AdminUser, Paginated } from "@/lib/types";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
|
import { toast } from "@/store/toasts";
|
||||||
|
|
||||||
const ROLE_OPTIONS = [
|
const ROLE_OPTIONS = [
|
||||||
{ value: "user", label: "User" },
|
{ value: "user", label: "User" },
|
||||||
@@ -17,8 +18,6 @@ const ROLE_OPTIONS = [
|
|||||||
function UserRow({ profile }: { profile: AdminUser }) {
|
function UserRow({ profile }: { profile: AdminUser }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [avatarJId, setAvatarJId] = useState(profile.avatar_j_id ?? "");
|
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({
|
const mutation = useMutation({
|
||||||
mutationFn: (payload: Record<string, string | null>) =>
|
mutationFn: (payload: Record<string, string | null>) =>
|
||||||
@@ -27,15 +26,11 @@ function UserRow({ profile }: { profile: AdminUser }) {
|
|||||||
json: payload,
|
json: payload,
|
||||||
}),
|
}),
|
||||||
onSuccess: (updated) => {
|
onSuccess: (updated) => {
|
||||||
setMessage("Saved.");
|
|
||||||
setError(null);
|
|
||||||
setAvatarJId(updated.avatar_j_id ?? "");
|
setAvatarJId(updated.avatar_j_id ?? "");
|
||||||
void queryClient.invalidateQueries({ queryKey: ["users"] });
|
void queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
toast.ok(`Saved ${updated.username}.`);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => toast.error(errorMessage(err)),
|
||||||
setError(errorMessage(err));
|
|
||||||
setMessage(null);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -105,13 +100,6 @@ function UserRow({ profile }: { profile: AdminUser }) {
|
|||||||
Save avatar
|
Save avatar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<boolean>;
|
||||||
|
settle: (confirmed: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useConfirm = create<ConfirmState>((set, get) => ({
|
||||||
|
request: null,
|
||||||
|
open: (options) =>
|
||||||
|
new Promise<boolean>((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<boolean> {
|
||||||
|
return useConfirm.getState().open(options);
|
||||||
|
}
|
||||||
@@ -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<ToastKind, number | null> = {
|
||||||
|
ok: 4_000,
|
||||||
|
info: 5_000,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let nextId = 1;
|
||||||
|
|
||||||
|
export const useToasts = create<ToastState>((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),
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user