From da74756c241b0c40f107f9d0adaf6749ac35b48e Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 30 Sep 2026 16:46:20 -0700 Subject: [PATCH] Redesign Activity: centered, grouped by day, calmer rows Activity is a centered page with a History/Quarantine switch in the header. Entries are grouped by day; each row has an action icon, the file name emphasized after its muted folder, and one line of details (action, time, trigger, pair). Badges only mark exceptions (failed, dry run, undone, purged), undone/purged entries are dimmed, quarantined items say so inline, and paging shows the range. The pair History tab uses the same rows. Co-Authored-By: Claude Opus 5.5 --- web/src/pages/Activity.tsx | 291 ++++++++++++++++++++++++++----------- 1 file changed, 205 insertions(+), 86 deletions(-) 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":