diff --git a/messages/de.json b/messages/de.json index dde97f3a8..5bb57afc6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index e045b6cab..673cb767a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 46777dd68..0d5065b20 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 9f404e4ad..7455b5a61 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 588e32e12..1a8a6582e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 6ec701414..adf178ba2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/components/documents/__tests__/document-bulk-bar.test.tsx b/src/components/documents/__tests__/document-bulk-bar.test.tsx new file mode 100644 index 000000000..df8a52613 --- /dev/null +++ b/src/components/documents/__tests__/document-bulk-bar.test.tsx @@ -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( + + {}} + onLinkEpisode={() => {}} + onDelete={() => {}} + onClear={() => {}} + /> + , + ); +} + +describe("", () => { + 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"); + }); +}); diff --git a/src/components/documents/__tests__/vault-utils.test.ts b/src/components/documents/__tests__/vault-utils.test.ts index 224b03cae..9cd8ae351 100644 --- a/src/components/documents/__tests__/vault-utils.test.ts +++ b/src/components/documents/__tests__/vault-utils.test.ts @@ -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([]); + }); +}); diff --git a/src/components/documents/document-bulk-bar.tsx b/src/components/documents/document-bulk-bar.tsx new file mode 100644 index 000000000..b506d6e0e --- /dev/null +++ b/src/components/documents/document-bulk-bar.tsx @@ -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 ( +
+

+ {t("documents.selection.count", { count: selectedCount })} +

+ +
+ + + + + + {DOCUMENT_KIND_ORDER.map((kind) => { + const Icon = DOCUMENT_KIND_ICONS[kind]; + return ( + onSetKind(kind)}> + + {t(`documents.kind.${kind}`)} + + ); + })} + + + + {episodes.length > 0 ? ( + + + + + + {episodes.map((episode) => ( + onLinkEpisode(episode.id)} + > + {episode.label} + + ))} + + + ) : null} + + + + +
+
+ ); +} diff --git a/src/components/documents/document-card.tsx b/src/components/documents/document-card.tsx index f54f9c571..387ce3395 100644 --- a/src/components/documents/document-card.tsx +++ b/src/components/documents/document-card.tsx @@ -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 | 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({

{title}

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} {/* 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. */} - - ) : null} - {list.isPending ? (
+ {selectedIds.size > 0 ? ( + ({ + 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 ? ( diff --git a/src/components/documents/vault-utils.ts b/src/components/documents/vault-utils.ts index fb5f96f50..92eba8f8e 100644 --- a/src/components/documents/vault-utils.ts +++ b/src/components/documents/vault-utils.ts @@ -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, + anchorId: string | null, + targetId: string, +): Set { + 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) ─────────────────────────────────────── /**