diff --git a/web/src/pages/Activity.tsx b/web/src/pages/Activity.tsx index eaa2692..23074a7 100644 --- a/web/src/pages/Activity.tsx +++ b/web/src/pages/Activity.tsx @@ -1,6 +1,21 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Archive, History, RotateCcw, Trash2 } from "lucide-react"; +import { + Archive, + ArchiveRestore, + ChevronLeft, + ChevronRight, + CircleAlert, + Crown, + FolderPen, + FolderPlus, + FolderX, + History, + Link2, + Trash2, + Undo2, + Unlink, +} from "lucide-react"; import { api, type Operation, type Page, type Pair } from "../api"; import { Badge, Button, Card, Empty, Modal, Spinner, cx, formatAgo, formatBytes } from "../ui"; import { useToast } from "../toast"; @@ -8,46 +23,84 @@ import { useToast } from "../toast"; export function ActivityPage() { const [view, setView] = useState<"all" | "quarantine">("all"); return ( -
-
+
+

Activity

-

Every change Playlink makes is recorded here and can be undone.

+

+ {view === "all" + ? "Every change Playlink makes is recorded here and can be undone." + : "Originals kept after being replaced, and folders removed from a library. Restore them, or delete them for good."} +

-
- {(["all", "quarantine"] as const).map((v) => ( +
+ {( + [ + ["all", "History", History], + ["quarantine", "Quarantine", Archive], + ] as const + ).map(([v, label, Icon]) => ( ))}
-
+
); } const actionLabel: Record = { - link: "Linked slave → master", - promote: "Promoted slave → master", + link: "Linked to master", + promote: "Promoted to master", build: "Built into slave", remove: "Removed from slave", rename: "Renamed slave folder", "remove-folder": "Removed folder without music", }; -const statusTone: Record = { - done: "green", - failed: "red", - planned: "blue", - interrupted: "amber", - running: "gray", +const actionIcon: Record = { + link: Link2, + promote: Crown, + build: FolderPlus, + remove: Unlink, + rename: FolderPen, + "remove-folder": FolderX, }; +// Only exceptions get a badge: "done" is the normal case. +const statusBadge: Partial> = { + failed: ["red", "failed"], + planned: ["blue", "planned"], + interrupted: ["amber", "interrupted"], + running: ["gray", "running"], +}; + +const PAGE = 50; + +function dayLabel(unix: number) { + const d = new Date(unix * 1000); + const today = new Date(); + const yesterday = new Date(Date.now() - 86400000); + const same = (a: Date, b: Date) => a.toDateString() === b.toDateString(); + if (same(d, today)) return "Today"; + if (same(d, yesterday)) return "Yesterday"; + return d.toLocaleDateString(undefined, { + weekday: "long", + month: "long", + day: "numeric", + year: d.getFullYear() === today.getFullYear() ? undefined : "numeric", + }); +} + export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?: boolean }) { const [page, setPage] = useState(0); const [confirm, setConfirm] = useState(null); @@ -57,13 +110,17 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?: const names = Object.fromEntries((pairs.data ?? []).map((p) => [p.id, p.name])); const ops = useQuery({ queryKey: ["ops", pairId, quarantine, page], - queryFn: () => api.get>(`/operations?limit=50&offset=${page * 50}${pairId ? `&pair=${pairId}` : ""}${quarantine ? "&quarantine=1" : ""}`), + queryFn: () => + api.get>( + `/operations?limit=${PAGE}&offset=${page * PAGE}${pairId ? `&pair=${pairId}` : ""}${quarantine ? "&quarantine=1" : ""}`, + ), placeholderData: keepPreviousData, }); + useEffect(() => setPage(0), [quarantine, pairId]); const act = useMutation({ mutationFn: ({ op, kind }: { op: Operation; kind: "undo" | "purge" }) => api.post(`/operations/${op.id}/${kind}`), onSuccess: (_, { kind }) => { - toast("ok", kind === "undo" ? "Operation undone" : "Quarantined file permanently deleted"); + toast("ok", kind === "undo" ? "Operation undone" : "Permanently deleted from the quarantine"); setConfirm(null); qc.invalidateQueries({ queryKey: ["ops"] }); qc.invalidateQueries({ queryKey: ["matches"] }); @@ -74,74 +131,55 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?: if (ops.isLoading) return ; const items = ops.data?.items ?? []; + const total = ops.data?.total ?? 0; + // Group by day, newest first (the API already sorts newest first). + const days: { label: string; ops: Operation[] }[] = []; + for (const o of items) { + const label = dayLabel(o.created_at); + if (days.at(-1)?.label !== label) days.push({ label, ops: [] }); + days.at(-1)!.ops.push(o); + } return ( <> - - {items.length === 0 && ( - : } title={quarantine ? "Quarantine is empty" : "No activity yet"}> - {quarantine ? "Originals replaced by a non-identical file show up here until they are purged." : undefined} + {items.length === 0 ? ( + + : } + title={quarantine ? "The quarantine is empty" : "No activity yet"} + > + {quarantine + ? "Originals replaced by a different file, and removed folders, show up here until they are purged." + : "Links, promotions and other changes will be listed here."} - )} - {items.map((o) => { - // History may hold actions of removed features: shown, never undone. - const known = o.action in actionLabel; - const canUndo = known && o.status === "done" && !o.undone_at && !o.purged_at; - const inQuarantine = !!o.quarantine_path && !o.purged_at && !o.undone_at && (o.status === "done" || o.status === "interrupted"); - return ( -
-
-
{actionLabel[o.action] ?? o.action}
-
- {formatAgo(o.created_at)} · {o.trigger} - {!pairId && names[o.pair_id] && ` · ${names[o.pair_id]}`} -
-
-
-
- {o.action === "remove-folder" && {o.quarantine_side}:} - {o.rel_slave || o.rel_master} -
- {o.rel_new && o.rel_new !== o.rel_slave && o.rel_new !== o.rel_master && ( -
now {o.rel_new}
- )} - {o.error &&
{o.error}
} - {inQuarantine && ( -
- {o.action === "remove-folder" ? "folder" : `${o.quarantine_side} original`} kept in quarantine -
- )} -
-
- {o.bytes > 0 && o.status === "done" && !o.undone_at && −{formatBytes(o.bytes)}} - {o.dry_run ? "dry run" : o.status} - {o.undone_at && undone} - {o.purged_at && purged} -
-
- {canUndo && ( - - )} - {inQuarantine && ( - - )} -
-
- ); - })} -
- {ops.data && ops.data.total > 50 && ( -
- - + + ) : ( +
+ {days.map((d) => ( +
+

{d.label}

+ + {d.ops.map((o) => ( + setConfirm({ op: o, kind })} /> + ))} + +
+ ))} +
+ )} + {total > PAGE && ( +
+ + {page * PAGE + 1}–{Math.min((page + 1) * PAGE, total)} of {total} + +
+ + +
)} setConfirm(null)} title={confirm?.kind === "undo" ? "Undo this operation?" : "Permanently delete?"}> @@ -150,8 +188,7 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?: {confirm.kind === "purge" ? ( <> The quarantined {confirm.op.action === "remove-folder" ? "folder" : `${confirm.op.quarantine_side} original of`}{" "} - {confirm.op.rel_slave || confirm.op.rel_master} will be deleted.{" "} - This cannot be undone. + {confirm.op.rel_slave || confirm.op.rel_master} will be deleted. This cannot be undone. ) : ( undoText(confirm.op) @@ -171,6 +208,88 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?: ); } +function OpRow({ o, pairName, onAct }: { o: Operation; pairName?: string; onAct: (kind: "undo" | "purge") => void }) { + // History may hold actions of removed features: shown, never undone. + const known = o.action in actionLabel; + const canUndo = known && o.status === "done" && !o.undone_at && !o.purged_at; + const inQuarantine = !!o.quarantine_path && !o.purged_at && !o.undone_at && (o.status === "done" || o.status === "interrupted"); + const Icon = actionIcon[o.action] ?? History; + const path = o.rel_slave || o.rel_master; + const slash = path.lastIndexOf("/"); + const [dir, base] = slash >= 0 ? [path.slice(0, slash + 1), path.slice(slash + 1)] : ["", path]; + const status = statusBadge[o.status]; + const faded = !!o.undone_at || !!o.purged_at || o.status === "failed"; + const time = new Date(o.created_at * 1000).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); + + return ( +
+
+ +
+
+
+ {o.action === "remove-folder" && {o.quarantine_side}:} + {dir} + {base} +
+
+ {actionLabel[o.action] ?? o.action} + · + + {time} ({formatAgo(o.created_at)}) + + · + {o.trigger} + {pairName && ( + <> + · + {pairName} + + )} +
+ {o.rel_new && o.rel_new !== o.rel_slave && o.rel_new !== o.rel_master && ( +
+ now {o.rel_new} +
+ )} + {o.error && ( +
+ {o.error} +
+ )} + {inQuarantine && ( +
+ {o.action === "remove-folder" ? "Folder" : `The ${o.quarantine_side} original`} is kept in the quarantine +
+ )} +
+
+ {o.bytes > 0 && o.status === "done" && !o.undone_at && −{formatBytes(o.bytes)}} + {o.dry_run && dry run} + {status && !o.dry_run && {status[1]}} + {o.undone_at && undone} + {o.purged_at && purged} + {canUndo && ( + + )} + {inQuarantine && ( + + )} +
+
+ ); +} + function undoText(o: Operation) { switch (o.action) { case "link":