feat(documents): multi-select bulk actions on the vault timeline

Selection grows file-manager gestures — shift-click ranges (additive,
anchored on the last plain toggle) and press-and-hold on touch — and a
floating toolbar appears with the selected count and the bulk verbs:
change type, link condition, delete, clear. All verbs drive the bulk
endpoint in <=100-id chunks with merged per-id results; partial failures
surface as one counted toast, and a bulk delete gets one aggregate undo
that bulk-restores the succeeded rows (both sides no-op-safe by
contract). Escape clears the selection unless a dialog owns the key.

Refs: document-vault plan W3-T2
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 13:21:16 +02:00
1 parent a8a733fc4b
commit aef2003d4c
13 files changed
+586 -45

No files matched your search

+11
View File
@@ -8104,6 +8104,17 @@
"dropOverlay": {
"title": "Zum Hochladen ablegen",
"hint": "Deine Dateien werden verschlüsselt in deinen Dokumenten gespeichert"
},
"bulk": {
"barLabel": "Aktionen für ausgewählte Dokumente",
"setKind": "Typ ändern",
"linkCondition": "Erkrankung verknüpfen",
"delete": "Löschen",
"updated": "{count} Dokument(e) aktualisiert",
"deleted": "{count} Dokument(e) gelöscht",
"restored": "{count} Dokument(e) wiederhergestellt",
"partialFailure": "{failed} von {total} konnten nicht aktualisiert werden",
"failed": "Die Aktion konnte nicht ausgeführt werden"
}
},
"selfDescription": {
+11
View File
@@ -8104,6 +8104,17 @@
"dropOverlay": {
"title": "Drop files to upload",
"hint": "They are stored encrypted in your document vault"
},
"bulk": {
"barLabel": "Actions for selected documents",
"setKind": "Change type",
"linkCondition": "Link condition",
"delete": "Delete",
"updated": "{count} document(s) updated",
"deleted": "{count} document(s) deleted",
"restored": "{count} document(s) restored",
"partialFailure": "{failed} of {total} could not be updated",
"failed": "The action could not be completed"
}
},
"selfDescription": {
+11
View File
@@ -8104,6 +8104,17 @@
"dropOverlay": {
"title": "Suelta los archivos para subirlos",
"hint": "Se guardan cifrados en tu archivo de documentos"
},
"bulk": {
"barLabel": "Acciones para los documentos seleccionados",
"setKind": "Cambiar tipo",
"linkCondition": "Vincular afección",
"delete": "Eliminar",
"updated": "{count} documento(s) actualizados",
"deleted": "{count} documento(s) eliminados",
"restored": "{count} documento(s) restaurados",
"partialFailure": "No se pudieron actualizar {failed} de {total}",
"failed": "No se pudo completar la acción"
}
},
"selfDescription": {
+11
View File
@@ -8104,6 +8104,17 @@
"dropOverlay": {
"title": "Déposez les fichiers pour les téléverser",
"hint": "Ils sont enregistrés chiffrés dans vos documents"
},
"bulk": {
"barLabel": "Actions pour les documents sélectionnés",
"setKind": "Changer le type",
"linkCondition": "Lier une affection",
"delete": "Supprimer",
"updated": "{count} document(s) mis à jour",
"deleted": "{count} document(s) supprimés",
"restored": "{count} document(s) restaurés",
"partialFailure": "Impossible de mettre à jour {failed} sur {total}",
"failed": "L'action n'a pas pu être effectuée"
}
},
"selfDescription": {
+11
View File
@@ -8104,6 +8104,17 @@
"dropOverlay": {
"title": "Rilascia i file per caricarli",
"hint": "Vengono salvati cifrati nei tuoi documenti"
},
"bulk": {
"barLabel": "Azioni per i documenti selezionati",
"setKind": "Cambia tipo",
"linkCondition": "Collega condizione",
"delete": "Elimina",
"updated": "{count} documenti aggiornati",
"deleted": "{count} documenti eliminati",
"restored": "{count} documenti ripristinati",
"partialFailure": "Impossibile aggiornare {failed} su {total}",
"failed": "Impossibile completare l'azione"
}
},
"selfDescription": {
+11
View File
@@ -8104,6 +8104,17 @@
"dropOverlay": {
"title": "Upuść pliki, aby je przesłać",
"hint": "Są zapisywane w Twoich dokumentach w postaci zaszyfrowanej"
},
"bulk": {
"barLabel": "Akcje dla zaznaczonych dokumentów",
"setKind": "Zmień typ",
"linkCondition": "Połącz ze schorzeniem",
"delete": "Usuń",
"updated": "Zaktualizowano dokumenty: {count}",
"deleted": "Usunięto dokumenty: {count}",
"restored": "Przywrócono dokumenty: {count}",
"partialFailure": "Nie udało się zaktualizować {failed} z {total}",
"failed": "Nie udało się wykonać akcji"
}
},
"selfDescription": {
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* The floating bulk bar, pinned via single-pass static renders: a labelled
* toolbar carrying the selected count and the four bulk verbs. The
* link-condition menu only renders when the account actually has episodes
* to link — no dead affordance on an episode-free account.
*/
import { I18nProvider } from "@/lib/i18n/context";
import { DocumentBulkBar } from "../document-bulk-bar";
function render(episodes: { id: string; label: string }[]) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DocumentBulkBar
selectedCount={3}
episodes={episodes}
busy={false}
onSetKind={() => {}}
onLinkEpisode={() => {}}
onDelete={() => {}}
onClear={() => {}}
/>
</I18nProvider>,
);
}
describe("<DocumentBulkBar>", () => {
it("renders a labelled toolbar with count and the bulk verbs", () => {
const html = render([{ id: "ep1", label: "Knee" }]);
expect(html).toContain('data-slot="document-bulk-bar"');
expect(html).toContain('role="toolbar"');
expect(html).toContain("3 selected");
expect(html).toContain("Change type");
expect(html).toContain("Link condition");
expect(html).toContain("Delete");
expect(html).toContain("Clear selection");
});
it("omits the link-condition verb when the account has no episodes", () => {
const html = render([]);
expect(html).not.toContain("Link condition");
expect(html).toContain("Change type");
});
});
@@ -7,6 +7,7 @@ import {
classifyUploadFailure,
countActiveFilters,
documentDateKey,
expandRangeSelection,
formatBytes,
formatMonthLabel,
parseUploadResponse,
@@ -252,3 +253,31 @@ describe("upload response contract (§3.2)", () => {
expect(parseUploadResponse(500, "")).toMatchObject({ reason: "generic" });
});
});
describe("shift-click range selection", () => {
const order = ["a", "b", "c", "d", "e"];
it("selects the inclusive range between anchor and target, both ways", () => {
expect(
[...expandRangeSelection(order, new Set(["b"]), "b", "d")].sort(),
).toEqual(["b", "c", "d"]);
expect(
[...expandRangeSelection(order, new Set(["d"]), "d", "b")].sort(),
).toEqual(["b", "c", "d"]);
});
it("is additive — a range gesture never deselects", () => {
const next = expandRangeSelection(order, new Set(["a", "c"]), "c", "e");
expect([...next].sort()).toEqual(["a", "c", "d", "e"]);
});
it("falls back to a plain toggle without a live anchor", () => {
expect([...expandRangeSelection(order, new Set(), null, "c")]).toEqual([
"c",
]);
// Anchor filtered out of the current order → plain toggle (deselect).
expect([
...expandRangeSelection(order, new Set(["c"]), "gone", "c"),
]).toEqual([]);
});
});
@@ -0,0 +1,134 @@
"use client";
/**
* Floating bulk-action bar for the vault's multi-select mode. Appears once
* at least one document is selected, pinned above the bottom nav on phones
* and near the bottom edge on desktop. Carries the selected count and the
* four bulk verbs — set type, link condition, delete (undo-able), clear —
* all driving `POST /api/documents/inbound/bulk` from the page.
*
* A `role="toolbar"` with proper labels; the destructive verb sits last
* before Clear so a stray tap sequence never ends on Delete. The bar is a
* deliberate hand-rolled shell (a floating toolbar is not a Card surface):
* dense-tile padding `p-3` per the standards.
*/
import { FolderPlus, Tag, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS, DOCUMENT_KIND_ORDER } from "./document-kind-meta";
export interface BulkEpisodeOption {
id: string;
label: string;
}
export function DocumentBulkBar({
selectedCount,
episodes,
busy,
onSetKind,
onLinkEpisode,
onDelete,
onClear,
}: {
selectedCount: number;
/** The caller's live illness episodes — the link-condition targets. */
episodes: BulkEpisodeOption[];
/** A bulk call is in flight — the verbs disable, Clear stays live. */
busy: boolean;
onSetKind: (kind: InboundDocumentKindValue) => void;
onLinkEpisode: (episodeId: string) => void;
onDelete: () => void;
onClear: () => void;
}) {
const { t } = useTranslations();
return (
<div
data-slot="document-bulk-bar"
role="toolbar"
aria-label={t("documents.bulk.barLabel")}
className={cn(
"bg-card border-border fixed bottom-20 left-1/2 z-40 -translate-x-1/2 md:bottom-6",
"flex w-[calc(100%-2rem)] max-w-2xl flex-wrap items-center gap-2 rounded-xl border p-3 shadow-lg",
)}
>
<p className="px-1 text-sm font-medium whitespace-nowrap" role="status">
{t("documents.selection.count", { count: selectedCount })}
</p>
<div className="ml-auto flex flex-wrap items-center justify-end gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={busy}>
<Tag className="size-4" aria-hidden />
{t("documents.bulk.setKind")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{DOCUMENT_KIND_ORDER.map((kind) => {
const Icon = DOCUMENT_KIND_ICONS[kind];
return (
<DropdownMenuItem key={kind} onSelect={() => onSetKind(kind)}>
<Icon className="size-4" aria-hidden />
{t(`documents.kind.${kind}`)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{episodes.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={busy}>
<FolderPlus className="size-4" aria-hidden />
{t("documents.bulk.linkCondition")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{episodes.map((episode) => (
<DropdownMenuItem
key={episode.id}
onSelect={() => onLinkEpisode(episode.id)}
>
<span className="max-w-56 truncate">{episode.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
disabled={busy}
onClick={onDelete}
>
<Trash2 className="size-4" aria-hidden />
{t("documents.bulk.delete")}
</Button>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={onClear}
>
<X className="size-3.5" aria-hidden />
{t("documents.selection.clear")}
</Button>
</div>
</div>
);
}
+71 -5
View File
@@ -14,6 +14,7 @@
* invisible overlay button; the checkbox floats above it.
*/
import { Download, X } from "lucide-react";
import { useRef } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -26,26 +27,50 @@ import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
import type { UploadQueueItem } from "./use-document-upload";
import { documentDateKey, formatBytes } from "./vault-utils";
/** Press-and-hold duration that flips a touch press into "select" mode. */
const LONG_PRESS_MS = 500;
export function DocumentCard({
document,
selected,
onToggleSelected,
onOpen,
onDelete,
highlighted,
tabIndex = 0,
onCardFocus,
onPrefetch,
}: {
document: InboundDocumentDto;
selected: boolean;
onToggleSelected: (id: string) => void;
/** `range` = extend the selection from the last anchor (shift-click). */
onToggleSelected: (id: string, range?: boolean) => void;
onOpen: (id: string) => void;
/** Delete key on the focused card — undo-able delete owned by the page. */
onDelete?: (id: string) => void;
/** Brief ring after a duplicate upload resolved to this existing row. */
highlighted: boolean;
/** Roving-tabindex slot from the timeline (0 = the one tabbable card). */
tabIndex?: number;
/** The open-button gained focus (keyboard/roving bookkeeping). */
onCardFocus?: (id: string) => void;
/** Hover/focus intent — prefetches the detail metadata (never the blob). */
onPrefetch?: (id: string) => void;
}) {
const { t, locale } = useTranslations();
const format = useFormatters();
// Touch long-press selects instead of opening; the click that the
// browser fires after the release is swallowed once.
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const longPressFired = useRef(false);
const cancelLongPress = () => {
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
};
const title =
document.title ?? document.filename ?? t("documents.card.untitled");
const Icon = DOCUMENT_KIND_ICONS[document.kind];
@@ -73,7 +98,14 @@ export function DocumentCard({
<p className="min-w-0 flex-1 truncate text-sm font-medium">{title}</p>
<Checkbox
checked={selected}
onCheckedChange={() => onToggleSelected(document.id)}
// Explicit click handling instead of onCheckedChange: the mouse
// event carries `shiftKey` for file-manager range selection.
// preventDefault stops Radix's internal toggle (state is fully
// controlled by the page's selection set anyway).
onClick={(e) => {
e.preventDefault();
onToggleSelected(document.id, e.shiftKey);
}}
aria-label={t("documents.card.selectLabel", { title })}
className={cn(
"relative z-10 shrink-0 transition-opacity",
@@ -110,12 +142,46 @@ export function DocumentCard({
) : null}
</CardContent>
{/* Whole-card click target. Painted last so it sits above the content
(the checkbox stays reachable via its own z-10). */}
(the checkbox stays reachable via its own z-10). Keyboard contract
(roving tabindex owned by the timeline): Enter opens (native
click), Space toggles selection, Delete removes with undo. Touch:
press-and-hold selects instead of opening. */}
<button
type="button"
onClick={() => onOpen(document.id)}
data-slot="document-open"
tabIndex={tabIndex}
onClick={() => {
if (longPressFired.current) {
longPressFired.current = false;
return;
}
onOpen(document.id);
}}
onKeyDown={(e) => {
if (e.key === " ") {
e.preventDefault();
onToggleSelected(document.id, e.shiftKey);
} else if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
onDelete?.(document.id);
}
}}
onTouchStart={() => {
longPressFired.current = false;
cancelLongPress();
longPressTimer.current = setTimeout(() => {
longPressFired.current = true;
onToggleSelected(document.id);
}, LONG_PRESS_MS);
}}
onTouchMove={cancelLongPress}
onTouchEnd={cancelLongPress}
onTouchCancel={cancelLongPress}
onMouseEnter={() => onPrefetch?.(document.id)}
onFocus={() => onPrefetch?.(document.id)}
onFocus={() => {
onCardFocus?.(document.id);
onPrefetch?.(document.id);
}}
aria-label={t("documents.card.openLabel", { title })}
className="focus-visible:ring-ring/50 absolute inset-0 rounded-xl focus-visible:ring-[3px] focus-visible:outline-none"
/>
@@ -56,7 +56,7 @@ export function DocumentTimeline({
isFetchingNextPage: boolean;
onLoadMore: () => void;
selectedIds: ReadonlySet<string>;
onToggleSelected: (id: string) => void;
onToggleSelected: (id: string, range?: boolean) => void;
onOpen: (id: string) => void;
highlightId: string | null;
onPrefetch?: (id: string) => void;
+205 -39
View File
@@ -16,6 +16,7 @@
*/
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
@@ -25,10 +26,10 @@ import {
SearchX,
Upload,
UploadCloud,
X,
} from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { useIllnessEpisodes } from "@/components/illness/use-illness";
import { Button } from "@/components/ui/button";
@@ -37,16 +38,20 @@ import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import type { DocumentVaultFilters } from "@/lib/query-keys/documents";
import type {
DocumentUsageDto,
InboundDocumentDetailDto,
InboundDocumentDto,
InboundDocumentKindValue,
import {
DOCUMENT_BULK_MAX_IDS,
type DocumentBulkAction,
type DocumentBulkResultDto,
type DocumentUsageDto,
type InboundDocumentDetailDto,
type InboundDocumentDto,
type InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import { DocumentBulkBar } from "./document-bulk-bar";
import { DocumentDetailSheet } from "./document-detail-sheet";
import { DocumentFilterBar, type ConditionChip } from "./document-filter-bar";
import { DocumentTimeline } from "./document-timeline";
@@ -57,6 +62,7 @@ import {
buildVaultListApiSearch,
countActiveFilters,
documentDateKey,
expandRangeSelection,
parseVaultSearchParams,
vaultFiltersToSearch,
} from "./vault-utils";
@@ -267,22 +273,187 @@ export function DocumentsView() {
return [...set].sort((a, b) => b - a);
}, [documents, filters.year]);
// ── Selection (bulk actions attach to this in a follow-up) ───────────
// ── Selection + bulk actions ──────────────────────────────────────────
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(
new Set(),
);
const toggleSelected = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
// Anchor for shift-click ranges: the last plainly-toggled id.
const rangeAnchorRef = useRef<string | null>(null);
const toggleSelected = useCallback(
(id: string, range?: boolean) => {
setSelectedIds((prev) => {
if (range) {
return expandRangeSelection(
documents.map((d) => d.id),
prev,
rangeAnchorRef.current,
id,
);
}
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
if (!range) rangeAnchorRef.current = id;
},
[documents],
);
const clearSelection = useCallback(() => {
setSelectedIds(new Set());
rangeAnchorRef.current = null;
}, []);
// Escape clears the selection — unless a dialog/popover owns the key.
useEffect(() => {
if (selectedIds.size === 0) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
const target = event.target as HTMLElement | null;
if (target?.closest('[role="dialog"],[role="menu"]')) return;
clearSelection();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [selectedIds.size, clearSelection]);
/**
* One bulk POST per ≤100-id chunk (the endpoint's cap); per-id results
* are merged so a partial failure in any chunk surfaces once. The
* endpoint is no-op-success on already-in-state rows, so an undo toast
* firing twice is safe by contract.
*/
const bulk = useMutation({
mutationFn: async (input: {
ids: string[];
action: DocumentBulkAction;
kind?: InboundDocumentKindValue;
episodeId?: string;
}) => {
const results: DocumentBulkResultDto[] = [];
for (let i = 0; i < input.ids.length; i += DOCUMENT_BULK_MAX_IDS) {
const chunk = input.ids.slice(i, i + DOCUMENT_BULK_MAX_IDS);
const page = await apiPost<{ results: DocumentBulkResultDto[] }>(
"/api/documents/inbound/bulk",
{
ids: chunk,
action: input.action,
...(input.kind !== undefined && { kind: input.kind }),
...(input.episodeId !== undefined && {
episodeId: input.episodeId,
}),
},
);
results.push(...page.results);
}
return results;
},
onSettled: () => {
void invalidateKeys(queryClient, [queryKeys.documents()]);
},
});
const reportBulkOutcome = useCallback(
(results: DocumentBulkResultDto[], successMessage: string) => {
const failed = results.filter((r) => !r.ok).length;
if (failed > 0) {
toast.error(
t("documents.bulk.partialFailure", {
failed,
total: results.length,
}),
);
} else {
toast.success(successMessage);
}
},
[t],
);
const runBulk = useCallback(
(
action: Exclude<DocumentBulkAction, "delete" | "restore">,
extra: { kind?: InboundDocumentKindValue; episodeId?: string },
) => {
const ids = [...selectedIds];
bulk.mutate(
{ ids, action, ...extra },
{
onSuccess: (results) => {
reportBulkOutcome(
results,
t("documents.bulk.updated", {
count: results.filter((r) => r.ok).length,
}),
);
clearSelection();
},
onError: () => toast.error(t("documents.bulk.failed")),
},
);
},
[selectedIds, bulk, reportBulkOutcome, clearSelection, t],
);
const restoreBulk = useCallback(
(ids: string[]) => {
bulk.mutate(
{ ids, action: "restore" },
{
onSuccess: (results) =>
reportBulkOutcome(
results,
t("documents.bulk.restored", {
count: results.filter((r) => r.ok).length,
}),
),
onError: () => toast.error(t("documents.toast.restoreFailed")),
},
);
},
[bulk, reportBulkOutcome, t],
);
const deleteBulk = useCallback(
(ids: string[]) => {
bulk.mutate(
{ ids, action: "delete" },
{
onSuccess: (results) => {
const okIds = results.filter((r) => r.ok).map((r) => r.id);
const failed = results.length - okIds.length;
if (failed > 0) {
toast.error(
t("documents.bulk.partialFailure", {
failed,
total: results.length,
}),
);
}
if (okIds.length > 0) {
// ONE aggregate undo for the whole batch (bulk restore).
toast.success(
t("documents.bulk.deleted", { count: okIds.length }),
{
action: {
label: t("common.undo"),
onClick: () => restoreBulk(okIds),
},
},
);
}
clearSelection();
},
onError: () => toast.error(t("documents.bulk.failed")),
},
);
},
[bulk, clearSelection, restoreBulk, t],
);
// ── Detail sheet ──────────────────────────────────────────────────────
const [detailId, setDetailId] = useState<string | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
@@ -357,26 +528,6 @@ export function DocumentsView() {
onClearAll={clearFilters}
/>
{selectedIds.size > 0 ? (
<div
data-slot="document-selection-bar"
className="flex items-center justify-between gap-3"
>
<p className="text-sm font-medium">
{t("documents.selection.count", { count: selectedIds.size })}
</p>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={() => setSelectedIds(new Set())}
>
<X className="size-3.5" aria-hidden />
{t("documents.selection.clear")}
</Button>
</div>
) : null}
{list.isPending ? (
<div
data-slot="documents-loading"
@@ -437,6 +588,21 @@ export function DocumentsView() {
onOpenChange={setDetailOpen}
/>
{selectedIds.size > 0 ? (
<DocumentBulkBar
selectedCount={selectedIds.size}
episodes={(episodes.data ?? []).map((e) => ({
id: e.id,
label: e.label,
}))}
busy={bulk.isPending}
onSetKind={(kind) => runBulk("setKind", { kind })}
onLinkEpisode={(episodeId) => runBulk("linkEpisode", { episodeId })}
onDelete={() => deleteBulk([...selectedIds])}
onClear={clearSelection}
/>
) : null}
{/* Page-wide drop overlay — pure decoration (aria-hidden); the intake
itself announces through the live region below. */}
{dropActive ? (
+34
View File
@@ -189,6 +189,40 @@ export function formatBytes(bytes: number, locale: string): string {
}).format(value)} ${units[unit]}`;
}
/**
* Shift-click range selection over the timeline's visual order. Selects
* every id between the last plainly-toggled anchor and the target
* (inclusive) — ADDITIVE, matching file-manager convention (a range gesture
* never deselects). Falls back to a plain toggle when the anchor is gone
* (filtered away, deleted) or was never set.
*/
export function expandRangeSelection(
orderedIds: readonly string[],
selected: ReadonlySet<string>,
anchorId: string | null,
targetId: string,
): Set<string> {
const next = new Set(selected);
const anchorIndex = anchorId === null ? -1 : orderedIds.indexOf(anchorId);
const targetIndex = orderedIds.indexOf(targetId);
if (anchorIndex === -1 || targetIndex === -1) {
if (next.has(targetId)) {
next.delete(targetId);
} else {
next.add(targetId);
}
return next;
}
const [from, to] =
anchorIndex <= targetIndex
? [anchorIndex, targetIndex]
: [targetIndex, anchorIndex];
for (let i = from; i <= to; i += 1) {
next.add(orderedIds[i]);
}
return next;
}
// ─── Upload response contract (§3.2) ───────────────────────────────────────
/**