From a8a733fc4b0a9899334d6f448ba967b1487be43d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 13:15:54 +0200 Subject: [PATCH] feat(documents): page-wide drop and clipboard paste feed the upload queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- messages/de.json | 7 +- messages/en.json | 7 +- messages/es.json | 7 +- messages/fr.json | 7 +- messages/it.json | 7 +- messages/pl.json | 7 +- src/components/documents/documents-view.tsx | 60 +++++++++++- .../documents/use-page-file-drop.ts | 93 +++++++++++++++++++ 8 files changed, 185 insertions(+), 10 deletions(-) create mode 100644 src/components/documents/use-page-file-drop.ts diff --git a/messages/de.json b/messages/de.json index d0b4c27ef..dde97f3a8 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 26c64091f..e045b6cab 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 419be8f10..46777dd68 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index e5703f349..9f404e4ad 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 9421beddd..588e32e12 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 1f1174cf1..6ec701414 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index b47c9a238..43a9be236 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -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(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() { - upload.enqueue(files, { episodeId: filters.episodeId }) - } + onFiles={enqueueFiles} /> + + {/* Page-wide drop overlay — pure decoration (aria-hidden); the intake + itself announces through the live region below. */} + {dropActive ? ( +
+
+
+ +

+ {t("documents.dropOverlay.title")} +

+

+ {t("documents.dropOverlay.hint")} +

+
+
+
+ ) : null} +

+ {uploadAnnouncement} +

); } diff --git a/src/components/documents/use-page-file-drop.ts b/src/components/documents/use-page-file-drop.ts new file mode 100644 index 000000000..a9fd06ff5 --- /dev/null +++ b/src/components/documents/use-page-file-drop.ts @@ -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 }; +}