web/src/routes/bundles.$name.tsx
12 KB · sha256:4113655c52650ffefa12bfeb17345efa8ef28f0b95bd708bd294d287ea47ee57
import { createFileRoute, Link } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowDown,
ArrowLeft,
ArrowUp,
ArrowUpDown,
Plus,
Trash2,
} from "lucide-react";
import { Fragment, useEffect, useState } from "react";
import { api } from "../lib/api";
import {
Button,
Card,
CardBody,
CardHeader,
EmptyState,
PageHeader,
Select,
} from "../components/ui";
import { PageShell } from "../components/layout";
export const Route = createFileRoute("/bundles/$name")({
component: BundleDetailPage,
});
function BundleDetailPage() {
const { name } = Route.useParams();
const qc = useQueryClient();
const bundle = useQuery({
queryKey: ["bundle", name],
queryFn: () => api.bundles.get(name),
});
const allGroups = useQuery({ queryKey: ["groups"], queryFn: api.groups.list });
// Local copy of the ordered list -- lets us reorder + remove instantly
// and PUT on save (debounced via a manual "Save order" button). Add
// mutations write through immediately.
const [order, setOrder] = useState<string[]>([]);
const [dirty, setDirty] = useState(false);
// Native HTML drag-and-drop state. MUST live before the loading-early-
// return below, otherwise the hook count changes between renders.
const [dragIdx, setDragIdx] = useState<number | null>(null);
// Visual drop indicator: which target row the mouse is over, and
// whether to drop above (`after: false`) or below (`after: true`) it
// -- chosen by comparing the cursor Y against the row's vertical
// midpoint, the standard pattern for list-reorder DnD.
const [hover, setHover] = useState<{ idx: number; after: boolean } | null>(
null,
);
useEffect(() => {
if (bundle.data) {
setOrder(bundle.data.groups);
setDirty(false);
}
}, [bundle.data]);
const invalidate = () => qc.invalidateQueries({ queryKey: ["bundle", name] });
const persistOrder = useMutation({
mutationFn: () => api.bundles.setGroups(name, order),
meta: { pending: "Saving order…", success: "Order saved" },
onSuccess: () => {
setDirty(false);
invalidate();
},
});
const addGroup = useMutation({
mutationFn: (g: string) => api.bundles.addGroup(name, g),
meta: { success: "Group added to bundle" },
onSuccess: invalidate,
});
const removeGroup = useMutation({
mutationFn: (g: string) => api.bundles.removeGroup(name, g),
meta: { success: "Group removed from bundle" },
onSuccess: invalidate,
});
const sortByWeight = useMutation({
mutationFn: async (dir: "asc" | "desc") => {
const groups = allGroups.data ?? [];
const weightOf = (n: string) =>
groups.find((g) => g.name === n)?.weight ?? 0;
const sorted = [...order].sort((a, b) =>
dir === "asc" ? weightOf(a) - weightOf(b) : weightOf(b) - weightOf(a),
);
setOrder(sorted);
await api.bundles.setGroups(name, sorted);
},
meta: { pending: "Sorting…", success: "Sorted by weight" },
onSuccess: invalidate,
});
const deleteBundle = useMutation({
mutationFn: () => api.bundles.delete(name),
meta: { pending: "Deleting bundle…", success: "Bundle deleted" },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bundles"] });
history.back();
},
});
if (bundle.isLoading) {
return (
<PageShell>
<p className="text-fg-muted">Loading…</p>
</PageShell>
);
}
if (!bundle.data) {
return (
<PageShell>
<EmptyState title="Bundle not found" description={name} />
</PageShell>
);
}
const b = bundle.data;
const candidates = (allGroups.data ?? [])
.map((g) => g.name)
.filter((g) => !order.includes(g));
const move = (idx: number, delta: number) => {
const next = [...order];
const tgt = idx + delta;
if (tgt < 0 || tgt >= next.length) return;
[next[idx], next[tgt]] = [next[tgt], next[idx]];
setOrder(next);
setDirty(true);
};
return (
<PageShell>
<Link
to="/bundles"
className="text-fg-muted hover:text-fg mb-4 inline-flex items-center gap-1 text-sm"
>
<ArrowLeft size={14} /> Bundles
</Link>
<PageHeader
title={b.displayName || b.name}
description={
b.displayName && b.displayName !== b.name ? (
<span className="font-mono text-xs">{b.name}</span>
) : (
<>
{order.length} group{order.length === 1 ? "" : "s"}
</>
)
}
action={
<Button
variant="danger"
size="sm"
onClick={() => {
if (confirm(`Delete bundle "${name}"? (Groups inside stay.)`))
deleteBundle.mutate();
}}
loading={deleteBundle.isPending}
>
<Trash2 size={14} /> Delete
</Button>
}
/>
<Card>
<CardHeader
title="Members"
description="Drag, or use the arrows, to reorder. Saved order is what /api/bundles returns + what 'Add from bundle' uses on tracks."
action={
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => sortByWeight.mutate("desc")}
loading={sortByWeight.isPending}
>
<ArrowUpDown size={14} /> Sort by weight (high → low)
</Button>
{dirty && (
<Button
size="sm"
variant="primary"
onClick={() => persistOrder.mutate()}
loading={persistOrder.isPending}
>
Save order
</Button>
)}
</div>
}
/>
<CardBody>
{order.length === 0 ? (
<EmptyState
title="Empty bundle"
description="Add groups below to populate this bundle."
/>
) : (
<ul
className="divide-border divide-y"
onDragLeave={(e) => {
// Only clear when leaving the list container, not when
// crossing between children (relatedTarget would be a
// child element in the latter case).
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setHover(null);
}
}}
>
{order.map((g, i) => (
<Fragment key={g}>
{hover && hover.idx === i && !hover.after && (
<DropIndicator />
)}
<li
draggable
onDragStart={(e) => {
setDragIdx(i);
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => {
if (dragIdx == null || dragIdx === i) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const rect = e.currentTarget.getBoundingClientRect();
const after =
e.clientY > rect.top + rect.height / 2;
setHover((prev) =>
prev?.idx === i && prev.after === after
? prev
: { idx: i, after },
);
}}
onDrop={(e) => {
e.preventDefault();
if (dragIdx == null) return;
const targetIdx = hover
? hover.after
? hover.idx + 1
: hover.idx
: i;
// After removing dragIdx, items after it shift up
// by one -- adjust the insert position if needed.
const adjusted =
dragIdx < targetIdx ? targetIdx - 1 : targetIdx;
if (adjusted === dragIdx) {
setDragIdx(null);
setHover(null);
return;
}
const next = [...order];
const [moved] = next.splice(dragIdx, 1);
next.splice(adjusted, 0, moved);
setOrder(next);
setDirty(true);
setDragIdx(null);
setHover(null);
}}
onDragEnd={() => {
setDragIdx(null);
setHover(null);
}}
className={
"flex items-center gap-3 py-2 transition " +
(dragIdx === i
? "opacity-40"
: "hover:bg-surface-3/40 rounded-md")
}
>
<span
className="text-fg-subtle cursor-grab select-none"
aria-hidden
>
⋮⋮
</span>
<span className="bg-brand-50 text-brand-700 dark:bg-brand-900/40 dark:text-brand-200 flex h-7 w-7 items-center justify-center rounded-md text-xs font-semibold tabular-nums">
{i + 1}
</span>
<Link
to="/groups/$name"
params={{ name: g }}
className="text-fg hover:text-brand-700 dark:hover:text-brand-300 flex-1 font-medium"
>
{g}
</Link>
<div className="flex items-center gap-1">
<IconBtn
onClick={() => move(i, -1)}
disabled={i === 0}
label="Move up"
>
<ArrowUp size={14} />
</IconBtn>
<IconBtn
onClick={() => move(i, 1)}
disabled={i === order.length - 1}
label="Move down"
>
<ArrowDown size={14} />
</IconBtn>
<IconBtn
onClick={() => removeGroup.mutate(g)}
label="Remove"
className="hover:text-red-600"
>
<Trash2 size={14} />
</IconBtn>
</div>
</li>
{hover && hover.idx === i && hover.after && (
<DropIndicator />
)}
</Fragment>
))}
</ul>
)}
{candidates.length > 0 && (
<div className="border-border mt-4 flex items-center gap-2 border-t pt-4">
<Plus size={14} className="text-fg-subtle" />
<Select
value=""
onChange={(e) => {
if (e.target.value) addGroup.mutate(e.target.value);
}}
className="max-w-xs"
>
<option value="">Add group to bundle…</option>
{candidates.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</Select>
</div>
)}
</CardBody>
</Card>
</PageShell>
);
}
/**
* Thin animated bar slotted into the reorder list at the position the
* dragged item will land. Uses `aria-hidden` because it's purely visual
* (keyboard users move via Up/Down buttons, which announce themselves).
*/
function DropIndicator() {
return (
<li
aria-hidden
className="bg-brand-500 dark:bg-brand-400 my-1 h-0.5 rounded-full transition-all"
/>
);
}
function IconBtn({
onClick,
disabled,
label,
className,
children,
}: {
onClick: () => void;
disabled?: boolean;
label: string;
className?: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
className={
"text-fg-subtle hover:text-fg hover:bg-surface-3 inline-flex h-7 w-7 items-center justify-center rounded-md transition disabled:cursor-not-allowed disabled:opacity-30 " +
(className ?? "")
}
>
{children}
</button>
);
}