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]) => (
setView(v)}
- className={cx("rounded-md px-3 py-1 text-sm capitalize cursor-pointer", view === v ? "bg-panel-2 text-white" : "text-muted")}
+ className={cx(
+ "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm cursor-pointer",
+ view === v ? "bg-accent/12 text-white" : "text-muted hover:text-white",
+ )}
>
- {v === "all" ? "History" : "Quarantine"}
+ {label}
))}
-
+
);
}
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 && (
- setConfirm({ op: o, kind: "undo" })}>
- {o.quarantine_path ? "Restore" : "Undo"}
-
- )}
- {inQuarantine && (
- setConfirm({ op: o, kind: "purge" })} title="Delete quarantined original">
-
-
- )}
-
-
- );
- })}
-
- {ops.data && ops.data.total > 50 && (
-
-
setPage(page - 1)}>
- Previous
-
-
= ops.data.total} onClick={() => setPage(page + 1)}>
- Next
-
+
+ ) : (
+
+ {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}
+
+
+ setPage(page - 1)}>
+ Newer
+
+ = total} onClick={() => setPage(page + 1)}>
+ Older
+
+
)}
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 && (
+
onAct("undo")}>
+ {o.quarantine_path ? : } {o.quarantine_path ? "Restore" : "Undo"}
+
+ )}
+ {inQuarantine && (
+
onAct("purge")} title="Delete from the quarantine for good">
+
+
+ )}
+
+
+ );
+}
+
function undoText(o: Operation) {
switch (o.action) {
case "link":