mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
e9b8995486
commit
a8a733fc4b
8 files changed
+185
-10
No files matched your search
+6
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user