feat(documents): page-wide drop and clipboard paste feed the upload queue

Dragging files anywhere over /documents raises a dimmed full-page drop
overlay (Escape dismisses); dropping or pasting files/screenshots feeds
the same upload queue as the picker, pre-linked to the active condition
filter. The overlay is decorative (aria-hidden) — intake announces
through an sr-only live region. Copy translated in all six locales.

Refs: document-vault plan W3-T1
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 13:15:54 +02:00
1 parent e9b8995486
commit a8a733fc4b
8 files changed
+185 -10

No files matched your search

+6 -1
View File
@@ -8067,7 +8067,8 @@
"zoneHint": "PDF, Fotos und Office-Dateien bis je {maxSize}",
"browse": "Dateien auswählen",
"quotaAlmostFull": "Speicher fast voll",
"quotaUsed": "{used} von {quota} belegt"
"quotaUsed": "{used} von {quota} belegt",
"queuedAnnouncement": "{count} Datei(en) zur Upload-Warteschlange hinzugefügt"
},
"selection": {
"count": "{count} ausgewählt",
@@ -8099,6 +8100,10 @@
"noConditions": "Mit keiner Erkrankung verknüpft",
"previewUnavailable": "Keine Inline-Vorschau für diesen Dateityp",
"uploadedMeta": "Hinzugefügt am {date} · {size}"
},
"dropOverlay": {
"title": "Zum Hochladen ablegen",
"hint": "Deine Dateien werden verschlüsselt in deinen Dokumenten gespeichert"
}
},
"selfDescription": {
+6 -1
View File
@@ -8067,7 +8067,8 @@
"zoneHint": "PDF, photos, and office files up to {maxSize} each",
"browse": "Choose files",
"quotaAlmostFull": "Storage almost full",
"quotaUsed": "{used} of {quota} used"
"quotaUsed": "{used} of {quota} used",
"queuedAnnouncement": "{count} file(s) added to the upload queue"
},
"selection": {
"count": "{count} selected",
@@ -8099,6 +8100,10 @@
"noConditions": "Not linked to a condition",
"previewUnavailable": "No inline preview for this file type",
"uploadedMeta": "Added {date} · {size}"
},
"dropOverlay": {
"title": "Drop files to upload",
"hint": "They are stored encrypted in your document vault"
}
},
"selfDescription": {
+6 -1
View File
@@ -8067,7 +8067,8 @@
"zoneHint": "PDF, fotos y archivos de Office de hasta {maxSize} cada uno",
"browse": "Elegir archivos",
"quotaAlmostFull": "Almacenamiento casi lleno",
"quotaUsed": "{used} de {quota} usados"
"quotaUsed": "{used} de {quota} usados",
"queuedAnnouncement": "{count} archivo(s) añadidos a la cola de subida"
},
"selection": {
"count": "{count} seleccionados",
@@ -8099,6 +8100,10 @@
"noConditions": "Sin vincular a una afección",
"previewUnavailable": "Sin vista previa integrada para este tipo de archivo",
"uploadedMeta": "Añadido el {date} · {size}"
},
"dropOverlay": {
"title": "Suelta los archivos para subirlos",
"hint": "Se guardan cifrados en tu archivo de documentos"
}
},
"selfDescription": {
+6 -1
View File
@@ -8067,7 +8067,8 @@
"zoneHint": "PDF, photos et fichiers Office jusqu'à {maxSize} chacun",
"browse": "Choisir des fichiers",
"quotaAlmostFull": "Stockage presque plein",
"quotaUsed": "{used} sur {quota} utilisés"
"quotaUsed": "{used} sur {quota} utilisés",
"queuedAnnouncement": "{count} fichier(s) ajoutés à la file de téléversement"
},
"selection": {
"count": "{count} sélectionnés",
@@ -8099,6 +8100,10 @@
"noConditions": "Lié à aucune affection",
"previewUnavailable": "Pas d'aperçu intégré pour ce type de fichier",
"uploadedMeta": "Ajouté le {date} · {size}"
},
"dropOverlay": {
"title": "Déposez les fichiers pour les téléverser",
"hint": "Ils sont enregistrés chiffrés dans vos documents"
}
},
"selfDescription": {
+6 -1
View File
@@ -8067,7 +8067,8 @@
"zoneHint": "PDF, foto e file Office fino a {maxSize} ciascuno",
"browse": "Scegli file",
"quotaAlmostFull": "Spazio quasi pieno",
"quotaUsed": "{used} di {quota} usati"
"quotaUsed": "{used} di {quota} usati",
"queuedAnnouncement": "{count} file aggiunti alla coda di caricamento"
},
"selection": {
"count": "{count} selezionati",
@@ -8099,6 +8100,10 @@
"noConditions": "Non collegato a una patologia",
"previewUnavailable": "Nessuna anteprima integrata per questo tipo di file",
"uploadedMeta": "Aggiunto il {date} · {size}"
},
"dropOverlay": {
"title": "Rilascia i file per caricarli",
"hint": "Vengono salvati cifrati nei tuoi documenti"
}
},
"selfDescription": {
+6 -1
View File
@@ -8067,7 +8067,8 @@
"zoneHint": "PDF, zdjęcia i pliki Office do {maxSize} każdy",
"browse": "Wybierz pliki",
"quotaAlmostFull": "Miejsce prawie pełne",
"quotaUsed": "Użyto {used} z {quota}"
"quotaUsed": "Użyto {used} z {quota}",
"queuedAnnouncement": "Dodano pliki do kolejki przesyłania: {count}"
},
"selection": {
"count": "Zaznaczono: {count}",
@@ -8099,6 +8100,10 @@
"noConditions": "Niepowiązany ze schorzeniem",
"previewUnavailable": "Brak podglądu dla tego typu pliku",
"uploadedMeta": "Dodano {date} · {size}"
},
"dropOverlay": {
"title": "Upuść pliki, aby je przesłać",
"hint": "Są zapisywane w Twoich dokumentach w postaci zaszyfrowanej"
}
},
"selfDescription": {
+56 -4
View File
@@ -19,7 +19,14 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { FolderOpen, Loader2, SearchX, Upload, X } from "lucide-react";
import {
FolderOpen,
Loader2,
SearchX,
Upload,
UploadCloud,
X,
} from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -45,6 +52,7 @@ import { DocumentFilterBar, type ConditionChip } from "./document-filter-bar";
import { DocumentTimeline } from "./document-timeline";
import { UploadZone } from "./upload-zone";
import { useDocumentUpload } from "./use-document-upload";
import { usePageFileDrop } from "./use-page-file-drop";
import {
buildVaultListApiSearch,
countActiveFilters,
@@ -207,6 +215,27 @@ export function DocumentsView() {
);
const uploadInputRef = useRef<HTMLInputElement | null>(null);
// One intake path for every source (picker, zone drop, page-wide drop,
// clipboard paste): pre-links to the active episode filter and feeds the
// sr-only live region — the drop overlay itself is decorative.
const [uploadAnnouncement, setUploadAnnouncement] = useState("");
const enqueueRef = useRef(upload.enqueue);
useEffect(() => {
enqueueRef.current = upload.enqueue;
}, [upload.enqueue]);
const episodeIdFilter = filters.episodeId;
const enqueueFiles = useCallback(
(files: File[]) => {
if (files.length === 0) return;
enqueueRef.current(files, { episodeId: episodeIdFilter });
setUploadAnnouncement(
t("documents.upload.queuedAnnouncement", { count: files.length }),
);
},
[episodeIdFilter, t],
);
const { dropActive } = usePageFileDrop(enqueueFiles);
const episodes = useIllnessEpisodes(true);
// Condition chips: the user's episodes that actually carry links in the
@@ -309,9 +338,7 @@ export function DocumentsView() {
<UploadZone
usage={usage.data}
inputRef={uploadInputRef}
onFiles={(files) =>
upload.enqueue(files, { episodeId: filters.episodeId })
}
onFiles={enqueueFiles}
/>
<DocumentFilterBar
@@ -409,6 +436,31 @@ export function DocumentsView() {
open={detailOpen}
onOpenChange={setDetailOpen}
/>
{/* Page-wide drop overlay — pure decoration (aria-hidden); the intake
itself announces through the live region below. */}
{dropActive ? (
<div
aria-hidden
data-slot="document-drop-overlay"
className="bg-background/80 fixed inset-0 z-50 p-4 backdrop-blur-sm md:p-8"
>
<div className="border-primary bg-primary/5 flex h-full items-center justify-center rounded-xl border-2 border-dashed">
<div className="flex flex-col items-center gap-2 px-4 text-center">
<UploadCloud className="text-primary size-8" aria-hidden />
<p className="text-base font-medium">
{t("documents.dropOverlay.title")}
</p>
<p className="text-muted-foreground text-xs">
{t("documents.dropOverlay.hint")}
</p>
</div>
</div>
</div>
) : null}
<p aria-live="polite" role="status" className="sr-only">
{uploadAnnouncement}
</p>
</div>
);
}
@@ -0,0 +1,93 @@
"use client";
/**
* Page-wide file intake for the vault: dragging files anywhere over the
* window raises a full-page drop overlay (the whole page is the target —
* not just the upload zone card), and pasting files/screenshots from the
* clipboard feeds the same queue. Window-level listeners, mounted only
* while `/documents` is on screen.
*
* Details that matter:
* - Only FILE drags react (`dataTransfer.types` includes `"Files"`) — text
* selections and in-page drags never raise the overlay.
* - `dragenter`/`dragleave` fire per element; a depth counter tells "left
* the window" apart from "moved between children".
* - `dragover` must be prevented while a file drag is over the window or
* the browser navigates to the dropped file on a miss.
* - Escape dismisses the overlay (the OS drag itself cannot be cancelled
* from the page; a subsequent drop then falls through to the browser
* default outside the page contract).
* - Paste only intercepts when the clipboard actually carries files, so
* pasting text into the search input stays untouched.
*/
import { useEffect, useRef, useState } from "react";
export function usePageFileDrop(onFiles: (files: File[]) => void): {
dropActive: boolean;
} {
const [dropActive, setDropActive] = useState(false);
const depthRef = useRef(0);
const onFilesRef = useRef(onFiles);
useEffect(() => {
onFilesRef.current = onFiles;
}, [onFiles]);
useEffect(() => {
const isFileDrag = (event: DragEvent) =>
event.dataTransfer?.types?.includes("Files") ?? false;
const onDragEnter = (event: DragEvent) => {
if (!isFileDrag(event)) return;
event.preventDefault();
depthRef.current += 1;
setDropActive(true);
};
const onDragOver = (event: DragEvent) => {
if (!isFileDrag(event)) return;
// Without this the browser handles the drop itself (navigates away).
event.preventDefault();
};
const onDragLeave = (event: DragEvent) => {
if (!isFileDrag(event)) return;
depthRef.current = Math.max(0, depthRef.current - 1);
if (depthRef.current === 0) setDropActive(false);
};
const onDrop = (event: DragEvent) => {
if (!isFileDrag(event)) return;
event.preventDefault();
depthRef.current = 0;
setDropActive(false);
const files = Array.from(event.dataTransfer?.files ?? []);
if (files.length > 0) onFilesRef.current(files);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape" || depthRef.current === 0) return;
depthRef.current = 0;
setDropActive(false);
};
const onPaste = (event: ClipboardEvent) => {
const files = Array.from(event.clipboardData?.files ?? []);
if (files.length === 0) return;
event.preventDefault();
onFilesRef.current(files);
};
window.addEventListener("dragenter", onDragEnter);
window.addEventListener("dragover", onDragOver);
window.addEventListener("dragleave", onDragLeave);
window.addEventListener("drop", onDrop);
window.addEventListener("keydown", onKeyDown);
window.addEventListener("paste", onPaste);
return () => {
window.removeEventListener("dragenter", onDragEnter);
window.removeEventListener("dragover", onDragOver);
window.removeEventListener("dragleave", onDragLeave);
window.removeEventListener("drop", onDrop);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("paste", onPaste);
};
}, []);
return { dropActive };
}