mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
a8a733fc4b
commit
aef2003d4c
13 files changed
+586
-45
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user