feat(playlists): act on the selected rows from the toolbar, and undo either

While rows are selected the toolbar's second row is the band: the count,
the last target as one click, Move to, Remove, Clear selection. Both write
in one request and undo back to the places; a video the target held before
stays there. Delete takes the selection when there is one. A failure puts
the rows back ticked.

A row's own move goes through the same write, so it undoes too, and the
single-row move route and service function are gone. A playlist row drops
its grip below a 24rem table: the row menu keeps the moves.
This commit is contained in:
2026-11-01 22:07:09 +01:00
parent 3b85881af4
commit efbbb23525
24 changed files with 1114 additions and 236 deletions
@@ -0,0 +1,101 @@
import type { ReactNode, Ref } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, FolderInput, History, X } from "lucide-react";
import { Button } from "../../components/ui/Button";
/** The playlist's second toolbar row while rows are selected: how many, and what can be done to them.
* `count` is the rows shown, what an action covers; `hidden` the selected ones a filter hides, which
* it leaves alone. With only hidden ones the actions stay in place, off, and the way out stays on. */
export function SelectionBand({
ref,
count,
hidden,
recentName,
onMoveRecent,
moveMenuProps,
moveMenuOpen,
onRemove,
onClear,
onClearView,
trailing,
}: {
ref: Ref<HTMLDivElement>;
count: number;
hidden: number;
/** The list the last move went to: one click away, absent when there is none to repeat. */
recentName: string | null;
onMoveRecent: () => void;
moveMenuProps: Record<string, unknown>;
moveMenuOpen: boolean;
onRemove: () => void;
onClear: () => void;
/** The view's reset, which the row this one replaces offers. */
onClearView?: () => void;
trailing: ReactNode;
}) {
const { t } = useTranslation();
const none = count === 0;
return (
<div ref={ref} className="flex items-center gap-2 mt-1 flex-wrap">
<span role="status" className="text-xs">
<span className="font-medium">{t("playlists.selectedCount", { count })}</span>
{hidden > 0 && (
<span className="text-muted">
{" · "}
<span>{t("playlists.selectedHidden", { count: hidden })}</span>
</span>
)}
</span>
{recentName != null && (
<Button
variant="outline"
size="xs"
disabled={none}
onClick={onMoveRecent}
icon={<History className="w-3.5 h-3.5" />}
>
<span className="block max-w-48 truncate">
{t("playlists.moveToRecent", { name: recentName })}
</span>
</Button>
)}
<Button
variant="outline"
size="xs"
disabled={none}
aria-expanded={moveMenuOpen}
icon={<FolderInput className="w-3.5 h-3.5" />}
{...moveMenuProps}
>
{t("playlists.moveSelected")}
<ChevronDown className="w-3.5 h-3.5" aria-hidden="true" />
</Button>
<Button
variant="outline-danger"
size="xs"
disabled={none}
onClick={onRemove}
icon={<X className="w-3.5 h-3.5" />}
>
{t("playlists.removeSelected")}
</Button>
<Button
variant="link-muted"
onClick={onClear}
className="text-xs underline underline-offset-2"
>
{t("playlists.clearSelection")}
</Button>
{onClearView && (
<Button
variant="link-muted"
onClick={onClearView}
className="text-xs underline underline-offset-2"
>
{t("playlists.clearView")}
</Button>
)}
<div className="ml-auto">{trailing}</div>
</div>
);
}