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 <noreply@anthropic.com>
This commit is contained in:
1 parent
074123a1c7
commit
da74756c24
1 file changed
+201
-82
+201
-82
@@ -1,6 +1,21 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { api, type Operation, type Page, type Pair } from "../api";
|
||||||
import { Badge, Button, Card, Empty, Modal, Spinner, cx, formatAgo, formatBytes } from "../ui";
|
import { Badge, Button, Card, Empty, Modal, Spinner, cx, formatAgo, formatBytes } from "../ui";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
@@ -8,46 +23,84 @@ import { useToast } from "../toast";
|
|||||||
export function ActivityPage() {
|
export function ActivityPage() {
|
||||||
const [view, setView] = useState<"all" | "quarantine">("all");
|
const [view, setView] = useState<"all" | "quarantine">("all");
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="mx-auto max-w-5xl space-y-6 pb-8">
|
||||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
<header className="flex flex-wrap items-end justify-between gap-4 px-1">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Activity</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Activity</h1>
|
||||||
<p className="text-sm text-muted">Every change Playlink makes is recorded here and can be undone.</p>
|
<p className="mt-1 text-sm text-muted">
|
||||||
|
{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."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex rounded-lg border border-line p-0.5">
|
<div className="flex rounded-lg border border-line bg-panel p-0.5">
|
||||||
{(["all", "quarantine"] as const).map((v) => (
|
{(
|
||||||
|
[
|
||||||
|
["all", "History", History],
|
||||||
|
["quarantine", "Quarantine", Archive],
|
||||||
|
] as const
|
||||||
|
).map(([v, label, Icon]) => (
|
||||||
<button
|
<button
|
||||||
key={v}
|
key={v}
|
||||||
onClick={() => setView(v)}
|
onClick={() => 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"}
|
<Icon className={cx("size-3.5", view === v && "text-accent")} /> {label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
<OpsTable quarantine={view === "quarantine"} />
|
<OpsTable quarantine={view === "quarantine"} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const actionLabel: Record<Operation["action"], string> = {
|
const actionLabel: Record<Operation["action"], string> = {
|
||||||
link: "Linked slave → master",
|
link: "Linked to master",
|
||||||
promote: "Promoted slave → master",
|
promote: "Promoted to master",
|
||||||
build: "Built into slave",
|
build: "Built into slave",
|
||||||
remove: "Removed from slave",
|
remove: "Removed from slave",
|
||||||
rename: "Renamed slave folder",
|
rename: "Renamed slave folder",
|
||||||
"remove-folder": "Removed folder without music",
|
"remove-folder": "Removed folder without music",
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusTone: Record<Operation["status"], "green" | "red" | "blue" | "amber" | "gray"> = {
|
const actionIcon: Record<Operation["action"], typeof Link2> = {
|
||||||
done: "green",
|
link: Link2,
|
||||||
failed: "red",
|
promote: Crown,
|
||||||
planned: "blue",
|
build: FolderPlus,
|
||||||
interrupted: "amber",
|
remove: Unlink,
|
||||||
running: "gray",
|
rename: FolderPen,
|
||||||
|
"remove-folder": FolderX,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Only exceptions get a badge: "done" is the normal case.
|
||||||
|
const statusBadge: Partial<Record<Operation["status"], ["red" | "blue" | "amber" | "gray", string]>> = {
|
||||||
|
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 }) {
|
export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?: boolean }) {
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [confirm, setConfirm] = useState<null | { op: Operation; kind: "undo" | "purge" }>(null);
|
const [confirm, setConfirm] = useState<null | { op: Operation; kind: "undo" | "purge" }>(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 names = Object.fromEntries((pairs.data ?? []).map((p) => [p.id, p.name]));
|
||||||
const ops = useQuery({
|
const ops = useQuery({
|
||||||
queryKey: ["ops", pairId, quarantine, page],
|
queryKey: ["ops", pairId, quarantine, page],
|
||||||
queryFn: () => api.get<Page<Operation>>(`/operations?limit=50&offset=${page * 50}${pairId ? `&pair=${pairId}` : ""}${quarantine ? "&quarantine=1" : ""}`),
|
queryFn: () =>
|
||||||
|
api.get<Page<Operation>>(
|
||||||
|
`/operations?limit=${PAGE}&offset=${page * PAGE}${pairId ? `&pair=${pairId}` : ""}${quarantine ? "&quarantine=1" : ""}`,
|
||||||
|
),
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
useEffect(() => setPage(0), [quarantine, pairId]);
|
||||||
const act = useMutation({
|
const act = useMutation({
|
||||||
mutationFn: ({ op, kind }: { op: Operation; kind: "undo" | "purge" }) => api.post(`/operations/${op.id}/${kind}`),
|
mutationFn: ({ op, kind }: { op: Operation; kind: "undo" | "purge" }) => api.post(`/operations/${op.id}/${kind}`),
|
||||||
onSuccess: (_, { 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);
|
setConfirm(null);
|
||||||
qc.invalidateQueries({ queryKey: ["ops"] });
|
qc.invalidateQueries({ queryKey: ["ops"] });
|
||||||
qc.invalidateQueries({ queryKey: ["matches"] });
|
qc.invalidateQueries({ queryKey: ["matches"] });
|
||||||
@@ -74,75 +131,56 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?:
|
|||||||
|
|
||||||
if (ops.isLoading) return <Spinner />;
|
if (ops.isLoading) return <Spinner />;
|
||||||
const items = ops.data?.items ?? [];
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card className="overflow-hidden">
|
{items.length === 0 ? (
|
||||||
{items.length === 0 && (
|
<Card>
|
||||||
<Empty icon={quarantine ? <Archive className="size-8" /> : <History className="size-8" />} title={quarantine ? "Quarantine is empty" : "No activity yet"}>
|
<Empty
|
||||||
{quarantine ? "Originals replaced by a non-identical file show up here until they are purged." : undefined}
|
icon={quarantine ? <Archive className="size-8" /> : <History className="size-8" />}
|
||||||
|
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."}
|
||||||
</Empty>
|
</Empty>
|
||||||
)}
|
|
||||||
{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 (
|
|
||||||
<div key={o.id} className="flex flex-wrap items-center gap-3 border-b border-line px-4 py-2.5 last:border-0">
|
|
||||||
<div className="w-40 shrink-0">
|
|
||||||
<div className="text-sm">{actionLabel[o.action] ?? o.action}</div>
|
|
||||||
<div className="text-[11px] text-muted">
|
|
||||||
{formatAgo(o.created_at)} · {o.trigger}
|
|
||||||
{!pairId && names[o.pair_id] && ` · ${names[o.pair_id]}`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate mono text-[13px]" title={o.rel_slave || o.rel_master}>
|
|
||||||
{o.action === "remove-folder" && <span className="mr-1.5 text-muted">{o.quarantine_side}:</span>}
|
|
||||||
{o.rel_slave || o.rel_master}
|
|
||||||
</div>
|
|
||||||
{o.rel_new && o.rel_new !== o.rel_slave && o.rel_new !== o.rel_master && (
|
|
||||||
<div className="truncate text-xs text-muted mono">now {o.rel_new}</div>
|
|
||||||
)}
|
|
||||||
{o.error && <div className="text-xs text-red-300">{o.error}</div>}
|
|
||||||
{inQuarantine && (
|
|
||||||
<div className="truncate text-xs text-amber-300/80 mono" title={o.quarantine_path}>
|
|
||||||
{o.action === "remove-folder" ? "folder" : `${o.quarantine_side} original`} kept in quarantine
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{o.bytes > 0 && o.status === "done" && !o.undone_at && <Badge tone="green">−{formatBytes(o.bytes)}</Badge>}
|
|
||||||
<Badge tone={statusTone[o.status]}>{o.dry_run ? "dry run" : o.status}</Badge>
|
|
||||||
{o.undone_at && <Badge>undone</Badge>}
|
|
||||||
{o.purged_at && <Badge>purged</Badge>}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
{canUndo && (
|
|
||||||
<Button size="sm" onClick={() => setConfirm({ op: o, kind: "undo" })}>
|
|
||||||
<RotateCcw className="size-3.5" /> {o.quarantine_path ? "Restore" : "Undo"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{inQuarantine && (
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => setConfirm({ op: o, kind: "purge" })} title="Delete quarantined original">
|
|
||||||
<Trash2 className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Card>
|
</Card>
|
||||||
{ops.data && ops.data.total > 50 && (
|
) : (
|
||||||
<div className="mt-3 flex justify-end gap-2">
|
<div className="space-y-5">
|
||||||
|
{days.map((d) => (
|
||||||
|
<section key={d.label}>
|
||||||
|
<h2 className="mb-2 px-1 text-xs font-medium uppercase tracking-wide text-muted">{d.label}</h2>
|
||||||
|
<Card className="divide-y divide-line">
|
||||||
|
{d.ops.map((o) => (
|
||||||
|
<OpRow key={o.id} o={o} pairName={!pairId ? names[o.pair_id] : undefined} onAct={(kind) => setConfirm({ op: o, kind })} />
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{total > PAGE && (
|
||||||
|
<div className="mt-4 flex items-center justify-between px-1 text-sm text-muted">
|
||||||
|
<span>
|
||||||
|
{page * PAGE + 1}–{Math.min((page + 1) * PAGE, total)} of {total}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button size="sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
|
<Button size="sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
|
||||||
Previous
|
<ChevronLeft className="size-3.5" /> Newer
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" disabled={(page + 1) * 50 >= ops.data.total} onClick={() => setPage(page + 1)}>
|
<Button size="sm" disabled={(page + 1) * PAGE >= total} onClick={() => setPage(page + 1)}>
|
||||||
Next
|
Older <ChevronRight className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<Modal open={!!confirm} onClose={() => setConfirm(null)} title={confirm?.kind === "undo" ? "Undo this operation?" : "Permanently delete?"}>
|
<Modal open={!!confirm} onClose={() => setConfirm(null)} title={confirm?.kind === "undo" ? "Undo this operation?" : "Permanently delete?"}>
|
||||||
{confirm && (
|
{confirm && (
|
||||||
@@ -150,8 +188,7 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?:
|
|||||||
{confirm.kind === "purge" ? (
|
{confirm.kind === "purge" ? (
|
||||||
<>
|
<>
|
||||||
The quarantined {confirm.op.action === "remove-folder" ? "folder" : `${confirm.op.quarantine_side} original of`}{" "}
|
The quarantined {confirm.op.action === "remove-folder" ? "folder" : `${confirm.op.quarantine_side} original of`}{" "}
|
||||||
<code className="mono break-all">{confirm.op.rel_slave || confirm.op.rel_master}</code> will be deleted.{" "}
|
<code className="mono break-all">{confirm.op.rel_slave || confirm.op.rel_master}</code> will be deleted. <b>This cannot be undone.</b>
|
||||||
<b>This cannot be undone.</b>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
undoText(confirm.op)
|
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 (
|
||||||
|
<div className="group flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3 hover:bg-panel-2/40">
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"grid size-9 shrink-0 place-items-center rounded-lg border",
|
||||||
|
o.status === "failed" ? "border-red-500/30 bg-red-500/10 text-red-300" : "border-accent/25 bg-accent/10 text-accent",
|
||||||
|
faded && o.status !== "failed" && "opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className={cx("min-w-[14rem] flex-1", faded && "opacity-70")}>
|
||||||
|
<div className="truncate text-sm" title={path}>
|
||||||
|
{o.action === "remove-folder" && <span className="mr-1.5 text-muted">{o.quarantine_side}:</span>}
|
||||||
|
<span className="text-muted">{dir}</span>
|
||||||
|
<span className="font-medium text-gray-100">{base}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted">
|
||||||
|
<span className="text-gray-300">{actionLabel[o.action] ?? o.action}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span title={new Date(o.created_at * 1000).toLocaleString()}>
|
||||||
|
{time} ({formatAgo(o.created_at)})
|
||||||
|
</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{o.trigger}</span>
|
||||||
|
{pairName && (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{pairName}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{o.rel_new && o.rel_new !== o.rel_slave && o.rel_new !== o.rel_master && (
|
||||||
|
<div className="mt-0.5 truncate text-xs text-muted">
|
||||||
|
now <span className="mono">{o.rel_new}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{o.error && (
|
||||||
|
<div className="mt-1 flex items-start gap-1 text-xs text-red-300">
|
||||||
|
<CircleAlert className="mt-px size-3.5 shrink-0" /> {o.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{inQuarantine && (
|
||||||
|
<div className="mt-1 flex items-center gap-1 text-xs text-amber-300/80" title={o.quarantine_path}>
|
||||||
|
<Archive className="size-3.5" /> {o.action === "remove-folder" ? "Folder" : `The ${o.quarantine_side} original`} is kept in the quarantine
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
|
{o.bytes > 0 && o.status === "done" && !o.undone_at && <span className="text-xs font-medium text-emerald-300">−{formatBytes(o.bytes)}</span>}
|
||||||
|
{o.dry_run && <Badge tone="blue">dry run</Badge>}
|
||||||
|
{status && !o.dry_run && <Badge tone={status[0]}>{status[1]}</Badge>}
|
||||||
|
{o.undone_at && <Badge>undone</Badge>}
|
||||||
|
{o.purged_at && <Badge>purged</Badge>}
|
||||||
|
{canUndo && (
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => onAct("undo")}>
|
||||||
|
{o.quarantine_path ? <ArchiveRestore className="size-3.5" /> : <Undo2 className="size-3.5" />} {o.quarantine_path ? "Restore" : "Undo"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{inQuarantine && (
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => onAct("purge")} title="Delete from the quarantine for good">
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function undoText(o: Operation) {
|
function undoText(o: Operation) {
|
||||||
switch (o.action) {
|
switch (o.action) {
|
||||||
case "link":
|
case "link":
|
||||||
|
|||||||
Reference in new issue
Block a user