From b8009d2e731d516cd0dca2a3f1782d933c9d8a4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 12:26:06 +0200 Subject: [PATCH] feat(documents): the vault surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page becomes one wide, calm filing-cabinet view: PageHeader with an Upload action, a drop-target upload zone whose accept list and per-file cap come from the usage endpoint (quota bar only above 80 %), a sticky filter rail (200 ms debounced search focused by '/', nine type chips, condition chips from linked episodes, a year segmenter, one-tap clear), and the virtualized timeline below. Filter state lives in the URL so every view is shareable and back-button-safe; uploads filtered to an episode arrive pre-linked. Hovering a card prefetches detail metadata — never the blob. Designed states: a skeleton grid that matches the loaded layout, a teaching empty state, a distinct no-matches state, and a retryable error card. /documents joins the shell's wide-container list; the shell stays the single owner of the page frame. Locale bundles gain the vault vocabulary in all six languages and drop the retired extraction-review strings. Plan-Task: W2-T1, W2-T4 (zone), W2-T5 (prefetch), W2-T6 --- messages/de.json | 183 +++----- messages/en.json | 183 +++----- messages/es.json | 183 +++----- messages/fr.json | 183 +++----- messages/it.json | 183 +++----- messages/pl.json | 183 +++----- src/app/documents/page.tsx | 51 +-- .../__tests__/documents-view-states.test.tsx | 97 ++++ .../documents/document-filter-bar.tsx | 202 +++++++++ src/components/documents/documents-view.tsx | 414 ++++++++++++++++++ src/components/documents/upload-zone.tsx | 142 ++++++ src/components/layout/auth-shell.tsx | 18 +- 12 files changed, 1268 insertions(+), 754 deletions(-) create mode 100644 src/components/documents/__tests__/documents-view-states.test.tsx create mode 100644 src/components/documents/document-filter-bar.tsx create mode 100644 src/components/documents/documents-view.tsx create mode 100644 src/components/documents/upload-zone.tsx diff --git a/messages/de.json b/messages/de.json index 91606adaf..d0b4c27ef 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8015,19 +8015,8 @@ }, "documents": { "title": "Dokumente", - "subtitle": "Speichere deine medizinischen Dokumente und prüfe, was darin steht.", - "upload": { - "label": "Dokument hinzufügen", - "kindLabel": "Kategorie", - "button": "Hochladen", - "uploading": "Wird hochgeladen…", - "fileLabel": "Datei (erforderlich)", - "titleLabel": "Titel (optional)", - "titlePlaceholder": "z. B. Kardiologie-Bericht", - "dateLabel": "Dokumentdatum (optional)", - "fileRequired": "Wähle eine Datei zum Hochladen.", - "accepts": "JPEG, PNG, WebP oder PDF · bis 12 MB" - }, + "subtitle": "Jeder Brief, Befund und Scan an einem durchsuchbaren Ort — verschlüsselt gespeichert.", + "pageUpload": "Hochladen", "kind": { "DOCTOR_REPORT": "Arztbericht", "DISCHARGE_LETTER": "Entlassungsbrief", @@ -8039,121 +8028,77 @@ "INSURANCE": "Versicherung", "VACCINATION": "Impfung" }, - "list": { - "empty": "Noch keine Dokumente.", - "noResults": "Keine Dokumente passen zu deinen Filtern.", - "loadMore": "Mehr laden", - "loadingMore": "Wird geladen…", - "pending": "{count} zu prüfen", - "facts": "{count} Angaben", - "clearFilters": "Filter zurücksetzen", - "loadError": "Deine Dokumente konnten nicht geladen werden." + "filter": { + "searchLabel": "Dokumente durchsuchen", + "searchPlaceholder": "Titel oder Dateiname suchen…", + "groupLabel": "Dokumente filtern", + "typeGroup": "Dokumenttyp", + "conditionGroup": "Erkrankung", + "yearGroup": "Jahr", + "clear": "Filter zurücksetzen" }, - "status": { - "EXTRACTING": "Wird gelesen", - "EXTRACTED": "Bereit zur Prüfung", - "FAILED": "Konnte nicht gelesen werden", - "CONFIRMED": "Geprüft", - "DISCARDED": "Verworfen", - "STORED": "Gespeichert" + "card": { + "untitled": "Unbenanntes Dokument", + "selectLabel": "{title} auswählen", + "openLabel": "{title} öffnen", + "attachmentBadge": "Nur Download", + "uploading": "Wird hochgeladen…", + "dismissFailed": "Fehlgeschlagenen Upload ausblenden" }, - "review": { - "title": "Erkannte Angaben prüfen", - "confirm": "Bestätigte Angaben speichern", - "confirming": "Wird gespeichert…", - "needsReview": "Prüfung nötig", - "approve": "Übernehmen", - "reject": "Ablehnen", - "edit": "Bearbeiten", - "cancel": "Abbrechen", - "save": "Speichern", - "needsReviewHint": "Bearbeite diese Angabe, um sie zu übernehmen.", - "confidenceLabel": "Erkennungssicherheit", - "commitError": { - "unitMismatch": "die Einheit stimmt nicht mit der gespeicherten überein", - "unitRequired": "ein Zahlenwert braucht eine Einheit", - "generic": "es konnte nicht gespeichert werden" - } - }, - "empty": { - "noFacts": "Nichts mehr zu prüfen." - }, - "factType": { - "CONDITION": "Diagnose", - "OBSERVATION": "Wert", - "MEDICATION_STATEMENT": "Medikament" - }, - "fields": { - "label": "Name", - "value": "Wert", - "unit": "Einheit", - "date": "Datum", - "dose": "Dosis" + "error": { + "fileTooLarge": "Diese Datei ist größer als das Limit von {maxSize}.", + "quotaExceeded": "Der Speicher ist voll — {used} von {quota} belegt.", + "unsupportedType": "Dieser Dateityp wird nicht unterstützt.", + "purged": "Dieses Dokument wurde endgültig entfernt und kann nicht wiederhergestellt werden.", + "duplicateExists": "Ein identisches Dokument ist bereits gespeichert.", + "rateLimited": "Zu viele Uploads — versuche es in ein paar Minuten erneut.", + "generic": "Upload fehlgeschlagen. Bitte erneut versuchen." }, "toast": { - "uploaded": "Dokument gespeichert.", - "uploadFailed": "Dokument konnte nicht gespeichert werden.", - "confirmed": "Bestätigte Angaben gespeichert.", - "confirmedPartial": "Gespeichert, aber {count} konnten nicht gespeichert werden — {reason}.", - "confirmFailed": "Deine Entscheidungen konnten nicht gespeichert werden.", - "saved": "Angabe aktualisiert.", - "discarded": "Dokument verworfen.", - "extracted": "Angaben ausgelesen. Prüfe sie unten.", - "extractFailed": "Angaben konnten nicht ausgelesen werden.", - "localOcrDisabled": "Aktiviere lokale OCR in den Einstellungen, um dieses Dokument auf dem Gerät zu lesen.", - "updated": "Dokument aktualisiert.", - "updateFailed": "Dokument konnte nicht aktualisiert werden.", - "discardFailed": "Dokument konnte nicht verworfen werden.", - "uploadTooLarge": "Datei ist zu groß (max. 12 MB).", - "uploadFileType": "Lade eine JPEG-, PNG-, WebP- oder PDF-Datei hoch.", - "uploadRateLimited": "Zu viele Uploads. Versuch es später erneut.", - "uploadInvalidMetadata": "Prüfe Titel, Kategorie oder Datum und versuch es erneut." + "duplicate": "Bereits gespeichert — das vorhandene Dokument wird hervorgehoben.", + "deleted": "Dokument gelöscht.", + "restoreFailed": "Das Dokument konnte nicht wiederhergestellt werden." }, - "toolbar": { - "searchLabel": "Suche", - "searchPlaceholder": "Titel oder Dateiname", - "kindFilterLabel": "Kategorie", - "allKinds": "Alle Kategorien", - "sortLabel": "Sortieren nach", - "sort": { - "documentDate": "Dokumentdatum", - "createdAt": "Hinzugefügt am", - "title": "Titel" - }, - "orderAsc": "Aufsteigend", - "orderDesc": "Absteigend", - "fromLabel": "Von", - "toLabel": "Bis" + "timeline": { + "loadingMore": "Mehr wird geladen…" + }, + "upload": { + "zoneTitle": "Dateien hier ablegen oder vom Gerät auswählen", + "zoneHint": "PDF, Fotos und Office-Dateien bis je {maxSize}", + "browse": "Dateien auswählen", + "quotaAlmostFull": "Speicher fast voll", + "quotaUsed": "{used} von {quota} belegt" + }, + "selection": { + "count": "{count} ausgewählt", + "clear": "Auswahl aufheben" + }, + "list": { + "loadError": "Deine Dokumente konnten nicht geladen werden." + }, + "empty": { + "title": "Dein Dokumenten-Tresor ist leer", + "description": "Bewahre Arztbriefe, Laborbefunde, Bildgebung, Rezepte und Impfnachweise auf — verschlüsselt und durchsuchbar nach Typ, Erkrankung und Jahr.", + "action": "Erstes Dokument hochladen", + "noMatchesTitle": "Keine passenden Dokumente", + "noMatchesDescription": "Nichts entspricht der aktuellen Suche und den Filtern." }, "detail": { - "viewOriginal": "Original ansehen", - "edit": "Bearbeiten", - "extract": "Angaben auslesen", - "extracting": "Wird ausgelesen…", - "discard": "Verwerfen", - "notExtracted": "Noch keine Angaben ausgelesen. Nutze „Angaben auslesen“, um dieses Dokument zu lesen.", - "viewOriginalNewTab": "(öffnet in einem neuen Tab)", - "discardConfirmTitle": "Dieses Dokument verwerfen?", - "discardConfirmBody": "Das löscht das Dokument, die Originaldatei und alle vorbereiteten Angaben dauerhaft. Das lässt sich nicht rückgängig machen.", - "discardConfirmAction": "Verwerfen", - "discardCancel": "Abbrechen" - }, - "edit": { - "title": "Dokument bearbeiten", - "subtitle": "Umbenennen, neu kategorisieren oder das Dokumentdatum setzen.", + "title": "Dokumentdetails", + "loadError": "Dieses Dokument konnte nicht geladen werden.", + "saveError": "Die Änderung konnte nicht gespeichert werden.", + "deleteError": "Das Dokument konnte nicht gelöscht werden.", + "download": "Herunterladen", + "delete": "Löschen", "titleLabel": "Titel", - "kindLabel": "Kategorie", + "titlePlaceholder": "Titel hinzufügen", + "kindLabel": "Typ", "dateLabel": "Dokumentdatum", - "save": "Speichern", - "saving": "Wird gespeichert…", - "cancel": "Abbrechen" - }, - "note": { - "providerUnsupported": "Richte in den KI-Einstellungen einen Anbieter für Dokumentenscans ein, um Angaben automatisch auszulesen.", - "alreadyConfirmed": "Dieses Dokument wurde bereits geprüft und kann nicht erneut ausgelesen werden." - }, - "committed": { - "title": "Aus diesem Dokument übernommen" + "conditionsLabel": "Verknüpfte Erkrankungen", + "linkCondition": "Verknüpfen", + "noConditions": "Mit keiner Erkrankung verknüpft", + "previewUnavailable": "Keine Inline-Vorschau für diesen Dateityp", + "uploadedMeta": "Hinzugefügt am {date} · {size}" } }, "selfDescription": { diff --git a/messages/en.json b/messages/en.json index d0068d128..26c64091f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8015,19 +8015,8 @@ }, "documents": { "title": "Documents", - "subtitle": "Store your clinical documents and review what they say.", - "upload": { - "label": "Add a document", - "kindLabel": "Category", - "button": "Upload", - "uploading": "Uploading…", - "fileLabel": "File (required)", - "titleLabel": "Title (optional)", - "titlePlaceholder": "e.g. Cardiology report", - "dateLabel": "Document date (optional)", - "fileRequired": "Choose a file to upload.", - "accepts": "JPEG, PNG, WebP or PDF · up to 12 MB" - }, + "subtitle": "Every letter, report, and scan in one searchable place — encrypted at rest.", + "pageUpload": "Upload", "kind": { "DOCTOR_REPORT": "Doctor report", "DISCHARGE_LETTER": "Discharge letter", @@ -8039,121 +8028,77 @@ "INSURANCE": "Insurance", "VACCINATION": "Vaccination" }, - "list": { - "empty": "No documents yet.", - "noResults": "No documents match your filters.", - "loadMore": "Load more", - "loadingMore": "Loading…", - "pending": "{count} to review", - "facts": "{count} facts", - "clearFilters": "Clear filters", - "loadError": "Couldn't load your documents." + "filter": { + "searchLabel": "Search documents", + "searchPlaceholder": "Search title or filename…", + "groupLabel": "Filter documents", + "typeGroup": "Document type", + "conditionGroup": "Condition", + "yearGroup": "Year", + "clear": "Clear filters" }, - "status": { - "EXTRACTING": "Reading", - "EXTRACTED": "Ready to review", - "FAILED": "Couldn't read", - "CONFIRMED": "Reviewed", - "DISCARDED": "Discarded", - "STORED": "Stored" + "card": { + "untitled": "Untitled document", + "selectLabel": "Select {title}", + "openLabel": "Open {title}", + "attachmentBadge": "Download only", + "uploading": "Uploading…", + "dismissFailed": "Dismiss failed upload" }, - "review": { - "title": "Review extracted facts", - "confirm": "Save approved facts", - "confirming": "Saving…", - "needsReview": "Needs review", - "approve": "Approve", - "reject": "Reject", - "edit": "Edit", - "cancel": "Cancel", - "save": "Save", - "needsReviewHint": "Edit this fact to approve it.", - "confidenceLabel": "Extraction confidence", - "commitError": { - "unitMismatch": "the unit doesn't match the saved one", - "unitRequired": "a numeric value needs a unit", - "generic": "it couldn't be saved" - } - }, - "empty": { - "noFacts": "Nothing left to review." - }, - "factType": { - "CONDITION": "Diagnosis", - "OBSERVATION": "Value", - "MEDICATION_STATEMENT": "Medication" - }, - "fields": { - "label": "Name", - "value": "Value", - "unit": "Unit", - "date": "Date", - "dose": "Dose" + "error": { + "fileTooLarge": "This file is larger than the {maxSize} limit.", + "quotaExceeded": "Storage is full — {used} of {quota} used.", + "unsupportedType": "This file type is not supported.", + "purged": "This document was permanently removed and cannot be restored.", + "duplicateExists": "An identical document is already stored.", + "rateLimited": "Too many uploads right now — try again in a few minutes.", + "generic": "Upload failed. Please try again." }, "toast": { - "uploaded": "Document stored.", - "uploadFailed": "Couldn't store the document.", - "confirmed": "Approved facts saved.", - "confirmedPartial": "Saved, but {count} couldn't be saved — {reason}.", - "confirmFailed": "Couldn't save your decisions.", - "saved": "Fact updated.", - "discarded": "Document discarded.", - "extracted": "Facts extracted. Review them below.", - "extractFailed": "Couldn't extract facts.", - "localOcrDisabled": "Enable local OCR in settings to read this document on-device.", - "updated": "Document updated.", - "updateFailed": "Couldn't update the document.", - "discardFailed": "Couldn't discard the document.", - "uploadTooLarge": "File is too large (max 12 MB).", - "uploadFileType": "Upload a JPEG, PNG, WebP, or PDF.", - "uploadRateLimited": "Too many uploads. Try again later.", - "uploadInvalidMetadata": "Check the title, category, or date and try again." + "duplicate": "Already stored — highlighting the existing document.", + "deleted": "Document deleted.", + "restoreFailed": "Couldn't restore the document." }, - "toolbar": { - "searchLabel": "Search", - "searchPlaceholder": "Title or filename", - "kindFilterLabel": "Category", - "allKinds": "All categories", - "sortLabel": "Sort by", - "sort": { - "documentDate": "Document date", - "createdAt": "Date added", - "title": "Title" - }, - "orderAsc": "Ascending", - "orderDesc": "Descending", - "fromLabel": "From", - "toLabel": "To" + "timeline": { + "loadingMore": "Loading more…" + }, + "upload": { + "zoneTitle": "Drop files here, or choose from your device", + "zoneHint": "PDF, photos, and office files up to {maxSize} each", + "browse": "Choose files", + "quotaAlmostFull": "Storage almost full", + "quotaUsed": "{used} of {quota} used" + }, + "selection": { + "count": "{count} selected", + "clear": "Clear selection" + }, + "list": { + "loadError": "Couldn't load your documents." + }, + "empty": { + "title": "Your document vault is empty", + "description": "Store doctor letters, lab reports, imaging, prescriptions, and vaccination records — encrypted, and searchable by type, condition, and year.", + "action": "Upload a first document", + "noMatchesTitle": "No matching documents", + "noMatchesDescription": "Nothing matches the current search and filters." }, "detail": { - "viewOriginal": "View original", - "edit": "Edit", - "extract": "Extract facts", - "extracting": "Extracting…", - "discard": "Discard", - "notExtracted": "No facts extracted yet. Use Extract facts to read this document.", - "viewOriginalNewTab": "(opens in a new tab)", - "discardConfirmTitle": "Discard this document?", - "discardConfirmBody": "This permanently deletes the document, its original file, and any staged facts. This can't be undone.", - "discardConfirmAction": "Discard", - "discardCancel": "Cancel" - }, - "edit": { - "title": "Edit document", - "subtitle": "Rename, recategorise, or set the document date.", + "title": "Document details", + "loadError": "Couldn't load this document.", + "saveError": "Couldn't save the change.", + "deleteError": "Couldn't delete the document.", + "download": "Download", + "delete": "Delete", "titleLabel": "Title", - "kindLabel": "Category", + "titlePlaceholder": "Add a title", + "kindLabel": "Type", "dateLabel": "Document date", - "save": "Save", - "saving": "Saving…", - "cancel": "Cancel" - }, - "note": { - "providerUnsupported": "Configure a document-scan provider in AI settings to extract facts automatically.", - "alreadyConfirmed": "This document has already been reviewed, so it can't be extracted again." - }, - "committed": { - "title": "Approved from this document" + "conditionsLabel": "Linked conditions", + "linkCondition": "Link", + "noConditions": "Not linked to a condition", + "previewUnavailable": "No inline preview for this file type", + "uploadedMeta": "Added {date} · {size}" } }, "selfDescription": { diff --git a/messages/es.json b/messages/es.json index 0144945d2..419be8f10 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8015,19 +8015,8 @@ }, "documents": { "title": "Documentos", - "subtitle": "Guarda tus documentos clínicos y revisa lo que dicen.", - "upload": { - "label": "Añadir un documento", - "kindLabel": "Categoría", - "button": "Subir", - "uploading": "Subiendo…", - "fileLabel": "Archivo (obligatorio)", - "titleLabel": "Título (opcional)", - "titlePlaceholder": "p. ej. Informe de cardiología", - "dateLabel": "Fecha del documento (opcional)", - "fileRequired": "Elige un archivo para subir.", - "accepts": "JPEG, PNG, WebP o PDF · hasta 12 MB" - }, + "subtitle": "Cada carta, informe y escaneo en un solo lugar con búsqueda — cifrado en reposo.", + "pageUpload": "Subir", "kind": { "DOCTOR_REPORT": "Informe médico", "DISCHARGE_LETTER": "Informe de alta", @@ -8039,121 +8028,77 @@ "INSURANCE": "Seguro", "VACCINATION": "Vacunación" }, - "list": { - "empty": "Aún no hay documentos.", - "noResults": "Ningún documento coincide con tus filtros.", - "loadMore": "Cargar más", - "loadingMore": "Cargando…", - "pending": "{count} por revisar", - "facts": "{count} datos", - "clearFilters": "Borrar filtros", - "loadError": "No se pudieron cargar tus documentos." + "filter": { + "searchLabel": "Buscar documentos", + "searchPlaceholder": "Buscar por título o nombre de archivo…", + "groupLabel": "Filtrar documentos", + "typeGroup": "Tipo de documento", + "conditionGroup": "Afección", + "yearGroup": "Año", + "clear": "Borrar filtros" }, - "status": { - "EXTRACTING": "Leyendo", - "EXTRACTED": "Listo para revisar", - "FAILED": "No se pudo leer", - "CONFIRMED": "Revisado", - "DISCARDED": "Descartado", - "STORED": "Guardado" + "card": { + "untitled": "Documento sin título", + "selectLabel": "Seleccionar {title}", + "openLabel": "Abrir {title}", + "attachmentBadge": "Solo descarga", + "uploading": "Subiendo…", + "dismissFailed": "Descartar subida fallida" }, - "review": { - "title": "Revisar datos extraídos", - "confirm": "Guardar datos aprobados", - "confirming": "Guardando…", - "needsReview": "Requiere revisión", - "approve": "Aprobar", - "reject": "Rechazar", - "edit": "Editar", - "cancel": "Cancelar", - "save": "Guardar", - "needsReviewHint": "Edita este dato para poder aprobarlo.", - "confidenceLabel": "Confianza de la extracción", - "commitError": { - "unitMismatch": "la unidad no coincide con la guardada", - "unitRequired": "un valor numérico necesita una unidad", - "generic": "no se pudo guardar" - } - }, - "empty": { - "noFacts": "Nada más que revisar." - }, - "factType": { - "CONDITION": "Diagnóstico", - "OBSERVATION": "Valor", - "MEDICATION_STATEMENT": "Medicación" - }, - "fields": { - "label": "Nombre", - "value": "Valor", - "unit": "Unidad", - "date": "Fecha", - "dose": "Dosis" + "error": { + "fileTooLarge": "Este archivo supera el límite de {maxSize}.", + "quotaExceeded": "El almacenamiento está lleno — {used} de {quota} usados.", + "unsupportedType": "Este tipo de archivo no es compatible.", + "purged": "Este documento se eliminó definitivamente y no se puede restaurar.", + "duplicateExists": "Ya hay guardado un documento idéntico.", + "rateLimited": "Demasiadas subidas — inténtalo de nuevo en unos minutos.", + "generic": "La subida falló. Inténtalo de nuevo." }, "toast": { - "uploaded": "Documento guardado.", - "uploadFailed": "No se pudo guardar el documento.", - "confirmed": "Datos aprobados guardados.", - "confirmedPartial": "Guardado, pero {count} no se pudieron guardar: {reason}.", - "confirmFailed": "No se pudieron guardar tus decisiones.", - "saved": "Dato actualizado.", - "discarded": "Documento descartado.", - "extracted": "Datos extraídos. Revísalos abajo.", - "extractFailed": "No se pudieron extraer los datos.", - "localOcrDisabled": "Activa el OCR local en los ajustes para leer este documento en el dispositivo.", - "updated": "Documento actualizado.", - "updateFailed": "No se pudo actualizar el documento.", - "discardFailed": "No se pudo descartar el documento.", - "uploadTooLarge": "El archivo es demasiado grande (máx. 12 MB).", - "uploadFileType": "Sube un archivo JPEG, PNG, WebP o PDF.", - "uploadRateLimited": "Demasiadas subidas. Inténtalo de nuevo más tarde.", - "uploadInvalidMetadata": "Revisa el título, la categoría o la fecha e inténtalo de nuevo." + "duplicate": "Ya estaba guardado — se resalta el documento existente.", + "deleted": "Documento eliminado.", + "restoreFailed": "No se pudo restaurar el documento." }, - "toolbar": { - "searchLabel": "Buscar", - "searchPlaceholder": "Título o nombre de archivo", - "kindFilterLabel": "Categoría", - "allKinds": "Todas las categorías", - "sortLabel": "Ordenar por", - "sort": { - "documentDate": "Fecha del documento", - "createdAt": "Fecha de adición", - "title": "Título" - }, - "orderAsc": "Ascendente", - "orderDesc": "Descendente", - "fromLabel": "Desde", - "toLabel": "Hasta" + "timeline": { + "loadingMore": "Cargando más…" + }, + "upload": { + "zoneTitle": "Suelta archivos aquí o elige desde tu dispositivo", + "zoneHint": "PDF, fotos y archivos de Office de hasta {maxSize} cada uno", + "browse": "Elegir archivos", + "quotaAlmostFull": "Almacenamiento casi lleno", + "quotaUsed": "{used} de {quota} usados" + }, + "selection": { + "count": "{count} seleccionados", + "clear": "Borrar selección" + }, + "list": { + "loadError": "No se pudieron cargar tus documentos." + }, + "empty": { + "title": "Tu archivo de documentos está vacío", + "description": "Guarda cartas médicas, resultados de laboratorio, imágenes, recetas y certificados de vacunación — cifrados y con búsqueda por tipo, afección y año.", + "action": "Subir un primer documento", + "noMatchesTitle": "No hay documentos coincidentes", + "noMatchesDescription": "Nada coincide con la búsqueda y los filtros actuales." }, "detail": { - "viewOriginal": "Ver original", - "edit": "Editar", - "extract": "Extraer datos", - "extracting": "Extrayendo…", - "discard": "Descartar", - "notExtracted": "Aún no se han extraído datos. Usa Extraer datos para leer este documento.", - "viewOriginalNewTab": "(se abre en una pestaña nueva)", - "discardConfirmTitle": "¿Descartar este documento?", - "discardConfirmBody": "Esto elimina de forma permanente el documento, su archivo original y los datos preparados. No se puede deshacer.", - "discardConfirmAction": "Descartar", - "discardCancel": "Cancelar" - }, - "edit": { - "title": "Editar documento", - "subtitle": "Renombra, recategoriza o fija la fecha del documento.", + "title": "Detalles del documento", + "loadError": "No se pudo cargar este documento.", + "saveError": "No se pudo guardar el cambio.", + "deleteError": "No se pudo eliminar el documento.", + "download": "Descargar", + "delete": "Eliminar", "titleLabel": "Título", - "kindLabel": "Categoría", + "titlePlaceholder": "Añadir un título", + "kindLabel": "Tipo", "dateLabel": "Fecha del documento", - "save": "Guardar", - "saving": "Guardando…", - "cancel": "Cancelar" - }, - "note": { - "providerUnsupported": "Configura un proveedor de escaneo de documentos en los ajustes de IA para extraer datos automáticamente.", - "alreadyConfirmed": "Este documento ya se ha revisado, por lo que no se puede volver a extraer." - }, - "committed": { - "title": "Aprobado de este documento" + "conditionsLabel": "Afecciones vinculadas", + "linkCondition": "Vincular", + "noConditions": "Sin vincular a una afección", + "previewUnavailable": "Sin vista previa integrada para este tipo de archivo", + "uploadedMeta": "Añadido el {date} · {size}" } }, "selfDescription": { diff --git a/messages/fr.json b/messages/fr.json index 22c1cace0..e5703f349 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8015,19 +8015,8 @@ }, "documents": { "title": "Documents", - "subtitle": "Conservez vos documents médicaux et vérifiez ce qu'ils contiennent.", - "upload": { - "label": "Ajouter un document", - "kindLabel": "Catégorie", - "button": "Téléverser", - "uploading": "Téléversement…", - "fileLabel": "Fichier (requis)", - "titleLabel": "Titre (facultatif)", - "titlePlaceholder": "p. ex. Compte rendu de cardiologie", - "dateLabel": "Date du document (facultatif)", - "fileRequired": "Choisissez un fichier à téléverser.", - "accepts": "JPEG, PNG, WebP ou PDF · jusqu'à 12 Mo" - }, + "subtitle": "Chaque courrier, compte rendu et scan au même endroit, consultable — chiffré au repos.", + "pageUpload": "Téléverser", "kind": { "DOCTOR_REPORT": "Compte rendu médical", "DISCHARGE_LETTER": "Lettre de sortie", @@ -8039,121 +8028,77 @@ "INSURANCE": "Assurance", "VACCINATION": "Vaccination" }, - "list": { - "empty": "Aucun document pour l'instant.", - "noResults": "Aucun document ne correspond à vos filtres.", - "loadMore": "Charger plus", - "loadingMore": "Chargement…", - "pending": "{count} à vérifier", - "facts": "{count} éléments", - "clearFilters": "Effacer les filtres", - "loadError": "Impossible de charger vos documents." + "filter": { + "searchLabel": "Rechercher des documents", + "searchPlaceholder": "Rechercher un titre ou un nom de fichier…", + "groupLabel": "Filtrer les documents", + "typeGroup": "Type de document", + "conditionGroup": "Affection", + "yearGroup": "Année", + "clear": "Effacer les filtres" }, - "status": { - "EXTRACTING": "Lecture", - "EXTRACTED": "Prêt à vérifier", - "FAILED": "Lecture impossible", - "CONFIRMED": "Vérifié", - "DISCARDED": "Écarté", - "STORED": "Conservé" + "card": { + "untitled": "Document sans titre", + "selectLabel": "Sélectionner {title}", + "openLabel": "Ouvrir {title}", + "attachmentBadge": "Téléchargement uniquement", + "uploading": "Téléversement…", + "dismissFailed": "Ignorer le téléversement échoué" }, - "review": { - "title": "Vérifier les données extraites", - "confirm": "Enregistrer les données approuvées", - "confirming": "Enregistrement…", - "needsReview": "À vérifier", - "approve": "Approuver", - "reject": "Rejeter", - "edit": "Modifier", - "cancel": "Annuler", - "save": "Enregistrer", - "needsReviewHint": "Modifie cette donnée pour pouvoir la valider.", - "confidenceLabel": "Confiance d'extraction", - "commitError": { - "unitMismatch": "l'unité ne correspond pas à celle enregistrée", - "unitRequired": "une valeur numérique nécessite une unité", - "generic": "il n'a pas pu être enregistré" - } - }, - "empty": { - "noFacts": "Plus rien à vérifier." - }, - "factType": { - "CONDITION": "Diagnostic", - "OBSERVATION": "Valeur", - "MEDICATION_STATEMENT": "Médicament" - }, - "fields": { - "label": "Nom", - "value": "Valeur", - "unit": "Unité", - "date": "Date", - "dose": "Dose" + "error": { + "fileTooLarge": "Ce fichier dépasse la limite de {maxSize}.", + "quotaExceeded": "Le stockage est plein — {used} sur {quota} utilisés.", + "unsupportedType": "Ce type de fichier n'est pas pris en charge.", + "purged": "Ce document a été définitivement supprimé et ne peut pas être restauré.", + "duplicateExists": "Un document identique est déjà enregistré.", + "rateLimited": "Trop de téléversements — réessayez dans quelques minutes.", + "generic": "Le téléversement a échoué. Veuillez réessayer." }, "toast": { - "uploaded": "Document enregistré.", - "uploadFailed": "Impossible d'enregistrer le document.", - "confirmed": "Données approuvées enregistrées.", - "confirmedPartial": "Enregistré, mais {count} n'ont pas pu être enregistrés — {reason}.", - "confirmFailed": "Impossible d'enregistrer vos décisions.", - "saved": "Donnée mise à jour.", - "discarded": "Document écarté.", - "extracted": "Éléments extraits. Vérifiez-les ci-dessous.", - "extractFailed": "Impossible d'extraire les éléments.", - "localOcrDisabled": "Activez l'OCR local dans les paramètres pour lire ce document sur l'appareil.", - "updated": "Document mis à jour.", - "updateFailed": "Impossible de mettre à jour le document.", - "discardFailed": "Impossible de supprimer le document.", - "uploadTooLarge": "Le fichier est trop volumineux (max. 12 Mo).", - "uploadFileType": "Importe un fichier JPEG, PNG, WebP ou PDF.", - "uploadRateLimited": "Trop d'envois. Réessaie plus tard.", - "uploadInvalidMetadata": "Vérifie le titre, la catégorie ou la date, puis réessaie." + "duplicate": "Déjà enregistré — le document existant est mis en évidence.", + "deleted": "Document supprimé.", + "restoreFailed": "Impossible de restaurer le document." }, - "toolbar": { - "searchLabel": "Rechercher", - "searchPlaceholder": "Titre ou nom de fichier", - "kindFilterLabel": "Catégorie", - "allKinds": "Toutes les catégories", - "sortLabel": "Trier par", - "sort": { - "documentDate": "Date du document", - "createdAt": "Date d'ajout", - "title": "Titre" - }, - "orderAsc": "Croissant", - "orderDesc": "Décroissant", - "fromLabel": "Du", - "toLabel": "Au" + "timeline": { + "loadingMore": "Chargement…" + }, + "upload": { + "zoneTitle": "Déposez des fichiers ici ou choisissez depuis votre appareil", + "zoneHint": "PDF, photos et fichiers Office jusqu'à {maxSize} chacun", + "browse": "Choisir des fichiers", + "quotaAlmostFull": "Stockage presque plein", + "quotaUsed": "{used} sur {quota} utilisés" + }, + "selection": { + "count": "{count} sélectionnés", + "clear": "Effacer la sélection" + }, + "list": { + "loadError": "Impossible de charger vos documents." + }, + "empty": { + "title": "Votre coffre à documents est vide", + "description": "Conservez courriers médicaux, résultats de laboratoire, imagerie, ordonnances et carnets de vaccination — chiffrés et consultables par type, affection et année.", + "action": "Téléverser un premier document", + "noMatchesTitle": "Aucun document correspondant", + "noMatchesDescription": "Rien ne correspond à la recherche et aux filtres actuels." }, "detail": { - "viewOriginal": "Voir l'original", - "edit": "Modifier", - "extract": "Extraire les éléments", - "extracting": "Extraction…", - "discard": "Écarter", - "notExtracted": "Aucun élément extrait pour l'instant. Utilisez Extraire les éléments pour lire ce document.", - "viewOriginalNewTab": "(s'ouvre dans un nouvel onglet)", - "discardConfirmTitle": "Supprimer ce document ?", - "discardConfirmBody": "Cela supprime définitivement le document, son fichier d'origine et les données préparées. Cette action est irréversible.", - "discardConfirmAction": "Supprimer", - "discardCancel": "Annuler" - }, - "edit": { - "title": "Modifier le document", - "subtitle": "Renommez, recatégorisez ou définissez la date du document.", + "title": "Détails du document", + "loadError": "Impossible de charger ce document.", + "saveError": "Impossible d'enregistrer la modification.", + "deleteError": "Impossible de supprimer le document.", + "download": "Télécharger", + "delete": "Supprimer", "titleLabel": "Titre", - "kindLabel": "Catégorie", + "titlePlaceholder": "Ajouter un titre", + "kindLabel": "Type", "dateLabel": "Date du document", - "save": "Enregistrer", - "saving": "Enregistrement…", - "cancel": "Annuler" - }, - "note": { - "providerUnsupported": "Configurez un fournisseur d'analyse de documents dans les paramètres d'IA pour extraire les éléments automatiquement.", - "alreadyConfirmed": "Ce document a déjà été vérifié ; il ne peut pas être extrait à nouveau." - }, - "committed": { - "title": "Validé depuis ce document" + "conditionsLabel": "Affections liées", + "linkCondition": "Lier", + "noConditions": "Lié à aucune affection", + "previewUnavailable": "Pas d'aperçu intégré pour ce type de fichier", + "uploadedMeta": "Ajouté le {date} · {size}" } }, "selfDescription": { diff --git a/messages/it.json b/messages/it.json index dd4dfbf76..9421beddd 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8015,19 +8015,8 @@ }, "documents": { "title": "Documenti", - "subtitle": "Conserva i tuoi documenti clinici e controlla cosa contengono.", - "upload": { - "label": "Aggiungi un documento", - "kindLabel": "Categoria", - "button": "Carica", - "uploading": "Caricamento…", - "fileLabel": "File (obbligatorio)", - "titleLabel": "Titolo (facoltativo)", - "titlePlaceholder": "es. Referto di cardiologia", - "dateLabel": "Data del documento (facoltativa)", - "fileRequired": "Scegli un file da caricare.", - "accepts": "JPEG, PNG, WebP o PDF · fino a 12 MB" - }, + "subtitle": "Ogni lettera, referto e scansione in un unico posto ricercabile — cifrato a riposo.", + "pageUpload": "Carica", "kind": { "DOCTOR_REPORT": "Referto medico", "DISCHARGE_LETTER": "Lettera di dimissione", @@ -8039,121 +8028,77 @@ "INSURANCE": "Assicurazione", "VACCINATION": "Vaccinazione" }, - "list": { - "empty": "Nessun documento ancora.", - "noResults": "Nessun documento corrisponde ai filtri.", - "loadMore": "Carica altri", - "loadingMore": "Caricamento…", - "pending": "{count} da rivedere", - "facts": "{count} dati", - "clearFilters": "Cancella i filtri", - "loadError": "Impossibile caricare i tuoi documenti." + "filter": { + "searchLabel": "Cerca documenti", + "searchPlaceholder": "Cerca titolo o nome file…", + "groupLabel": "Filtra documenti", + "typeGroup": "Tipo di documento", + "conditionGroup": "Patologia", + "yearGroup": "Anno", + "clear": "Cancella filtri" }, - "status": { - "EXTRACTING": "Lettura", - "EXTRACTED": "Pronto per la revisione", - "FAILED": "Lettura non riuscita", - "CONFIRMED": "Rivisto", - "DISCARDED": "Scartato", - "STORED": "Salvato" + "card": { + "untitled": "Documento senza titolo", + "selectLabel": "Seleziona {title}", + "openLabel": "Apri {title}", + "attachmentBadge": "Solo download", + "uploading": "Caricamento…", + "dismissFailed": "Ignora caricamento non riuscito" }, - "review": { - "title": "Rivedi i dati estratti", - "confirm": "Salva i dati approvati", - "confirming": "Salvataggio…", - "needsReview": "Da rivedere", - "approve": "Approva", - "reject": "Rifiuta", - "edit": "Modifica", - "cancel": "Annulla", - "save": "Salva", - "needsReviewHint": "Modifica questo dato per poterlo approvare.", - "confidenceLabel": "Affidabilità dell'estrazione", - "commitError": { - "unitMismatch": "l'unità non corrisponde a quella salvata", - "unitRequired": "un valore numerico richiede un'unità", - "generic": "non è stato possibile salvarlo" - } - }, - "empty": { - "noFacts": "Niente altro da rivedere." - }, - "factType": { - "CONDITION": "Diagnosi", - "OBSERVATION": "Valore", - "MEDICATION_STATEMENT": "Farmaco" - }, - "fields": { - "label": "Nome", - "value": "Valore", - "unit": "Unità", - "date": "Data", - "dose": "Dose" + "error": { + "fileTooLarge": "Questo file supera il limite di {maxSize}.", + "quotaExceeded": "Lo spazio è pieno — {used} di {quota} usati.", + "unsupportedType": "Questo tipo di file non è supportato.", + "purged": "Questo documento è stato rimosso definitivamente e non può essere ripristinato.", + "duplicateExists": "Un documento identico è già archiviato.", + "rateLimited": "Troppi caricamenti — riprova tra qualche minuto.", + "generic": "Caricamento non riuscito. Riprova." }, "toast": { - "uploaded": "Documento salvato.", - "uploadFailed": "Impossibile salvare il documento.", - "confirmed": "Dati approvati salvati.", - "confirmedPartial": "Salvato, ma {count} non è stato possibile salvare: {reason}.", - "confirmFailed": "Impossibile salvare le tue scelte.", - "saved": "Dato aggiornato.", - "discarded": "Documento scartato.", - "extracted": "Dati estratti. Rivedili qui sotto.", - "extractFailed": "Impossibile estrarre i dati.", - "localOcrDisabled": "Attiva l'OCR locale nelle impostazioni per leggere questo documento sul dispositivo.", - "updated": "Documento aggiornato.", - "updateFailed": "Impossibile aggiornare il documento.", - "discardFailed": "Impossibile eliminare il documento.", - "uploadTooLarge": "Il file è troppo grande (max 12 MB).", - "uploadFileType": "Carica un file JPEG, PNG, WebP o PDF.", - "uploadRateLimited": "Troppi caricamenti. Riprova più tardi.", - "uploadInvalidMetadata": "Controlla titolo, categoria o data e riprova." + "duplicate": "Già archiviato — il documento esistente viene evidenziato.", + "deleted": "Documento eliminato.", + "restoreFailed": "Impossibile ripristinare il documento." }, - "toolbar": { - "searchLabel": "Cerca", - "searchPlaceholder": "Titolo o nome file", - "kindFilterLabel": "Categoria", - "allKinds": "Tutte le categorie", - "sortLabel": "Ordina per", - "sort": { - "documentDate": "Data del documento", - "createdAt": "Data di aggiunta", - "title": "Titolo" - }, - "orderAsc": "Crescente", - "orderDesc": "Decrescente", - "fromLabel": "Dal", - "toLabel": "Al" + "timeline": { + "loadingMore": "Caricamento…" + }, + "upload": { + "zoneTitle": "Trascina i file qui o scegli dal dispositivo", + "zoneHint": "PDF, foto e file Office fino a {maxSize} ciascuno", + "browse": "Scegli file", + "quotaAlmostFull": "Spazio quasi pieno", + "quotaUsed": "{used} di {quota} usati" + }, + "selection": { + "count": "{count} selezionati", + "clear": "Annulla selezione" + }, + "list": { + "loadError": "Impossibile caricare i tuoi documenti." + }, + "empty": { + "title": "Il tuo archivio documenti è vuoto", + "description": "Conserva lettere mediche, referti di laboratorio, imaging, prescrizioni e certificati vaccinali — cifrati e ricercabili per tipo, patologia e anno.", + "action": "Carica un primo documento", + "noMatchesTitle": "Nessun documento corrispondente", + "noMatchesDescription": "Nulla corrisponde alla ricerca e ai filtri attuali." }, "detail": { - "viewOriginal": "Vedi originale", - "edit": "Modifica", - "extract": "Estrai dati", - "extracting": "Estrazione…", - "discard": "Scarta", - "notExtracted": "Nessun dato estratto. Usa Estrai dati per leggere questo documento.", - "viewOriginalNewTab": "(si apre in una nuova scheda)", - "discardConfirmTitle": "Eliminare questo documento?", - "discardConfirmBody": "Questo elimina definitivamente il documento, il file originale e i dati preparati. L'operazione non è reversibile.", - "discardConfirmAction": "Elimina", - "discardCancel": "Annulla" - }, - "edit": { - "title": "Modifica documento", - "subtitle": "Rinomina, ricategorizza o imposta la data del documento.", + "title": "Dettagli del documento", + "loadError": "Impossibile caricare questo documento.", + "saveError": "Impossibile salvare la modifica.", + "deleteError": "Impossibile eliminare il documento.", + "download": "Scarica", + "delete": "Elimina", "titleLabel": "Titolo", - "kindLabel": "Categoria", + "titlePlaceholder": "Aggiungi un titolo", + "kindLabel": "Tipo", "dateLabel": "Data del documento", - "save": "Salva", - "saving": "Salvataggio…", - "cancel": "Annulla" - }, - "note": { - "providerUnsupported": "Configura un provider di scansione documenti nelle impostazioni IA per estrarre i dati automaticamente.", - "alreadyConfirmed": "Questo documento è già stato esaminato, quindi non può essere estratto di nuovo." - }, - "committed": { - "title": "Approvato da questo documento" + "conditionsLabel": "Patologie collegate", + "linkCondition": "Collega", + "noConditions": "Non collegato a una patologia", + "previewUnavailable": "Nessuna anteprima integrata per questo tipo di file", + "uploadedMeta": "Aggiunto il {date} · {size}" } }, "selfDescription": { diff --git a/messages/pl.json b/messages/pl.json index 4d597f8b8..1f1174cf1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8015,19 +8015,8 @@ }, "documents": { "title": "Dokumenty", - "subtitle": "Przechowuj swoje dokumenty medyczne i sprawdzaj, co zawierają.", - "upload": { - "label": "Dodaj dokument", - "kindLabel": "Kategoria", - "button": "Prześlij", - "uploading": "Przesyłanie…", - "fileLabel": "Plik (wymagany)", - "titleLabel": "Tytuł (opcjonalnie)", - "titlePlaceholder": "np. Raport kardiologiczny", - "dateLabel": "Data dokumentu (opcjonalnie)", - "fileRequired": "Wybierz plik do przesłania.", - "accepts": "JPEG, PNG, WebP lub PDF · do 12 MB" - }, + "subtitle": "Każde pismo, wynik i skan w jednym przeszukiwalnym miejscu — zaszyfrowane w spoczynku.", + "pageUpload": "Prześlij", "kind": { "DOCTOR_REPORT": "Raport lekarski", "DISCHARGE_LETTER": "Wypis ze szpitala", @@ -8039,121 +8028,77 @@ "INSURANCE": "Ubezpieczenie", "VACCINATION": "Szczepienie" }, - "list": { - "empty": "Brak dokumentów.", - "noResults": "Żaden dokument nie pasuje do filtrów.", - "loadMore": "Załaduj więcej", - "loadingMore": "Ładowanie…", - "pending": "{count} do przeglądu", - "facts": "{count} faktów", - "clearFilters": "Wyczyść filtry", - "loadError": "Nie udało się wczytać Twoich dokumentów." + "filter": { + "searchLabel": "Szukaj dokumentów", + "searchPlaceholder": "Szukaj tytułu lub nazwy pliku…", + "groupLabel": "Filtruj dokumenty", + "typeGroup": "Typ dokumentu", + "conditionGroup": "Schorzenie", + "yearGroup": "Rok", + "clear": "Wyczyść filtry" }, - "status": { - "EXTRACTING": "Odczyt", - "EXTRACTED": "Gotowy do przeglądu", - "FAILED": "Nie udało się odczytać", - "CONFIRMED": "Przejrzany", - "DISCARDED": "Odrzucony", - "STORED": "Zapisany" + "card": { + "untitled": "Dokument bez tytułu", + "selectLabel": "Zaznacz {title}", + "openLabel": "Otwórz {title}", + "attachmentBadge": "Tylko do pobrania", + "uploading": "Przesyłanie…", + "dismissFailed": "Odrzuć nieudane przesyłanie" }, - "review": { - "title": "Przejrzyj wyodrębnione dane", - "confirm": "Zapisz zatwierdzone dane", - "confirming": "Zapisywanie…", - "needsReview": "Wymaga przeglądu", - "approve": "Zatwierdź", - "reject": "Odrzuć", - "edit": "Edytuj", - "cancel": "Anuluj", - "save": "Zapisz", - "needsReviewHint": "Edytuj tę pozycję, aby ją zatwierdzić.", - "confidenceLabel": "Pewność rozpoznania", - "commitError": { - "unitMismatch": "jednostka nie zgadza się z zapisaną", - "unitRequired": "wartość liczbowa wymaga jednostki", - "generic": "nie udało się zapisać" - } - }, - "empty": { - "noFacts": "Nic więcej do przejrzenia." - }, - "factType": { - "CONDITION": "Diagnoza", - "OBSERVATION": "Wartość", - "MEDICATION_STATEMENT": "Lek" - }, - "fields": { - "label": "Nazwa", - "value": "Wartość", - "unit": "Jednostka", - "date": "Data", - "dose": "Dawka" + "error": { + "fileTooLarge": "Ten plik przekracza limit {maxSize}.", + "quotaExceeded": "Miejsce jest pełne — użyto {used} z {quota}.", + "unsupportedType": "Ten typ pliku nie jest obsługiwany.", + "purged": "Ten dokument został trwale usunięty i nie można go przywrócić.", + "duplicateExists": "Identyczny dokument jest już zapisany.", + "rateLimited": "Zbyt wiele przesyłań — spróbuj ponownie za kilka minut.", + "generic": "Przesyłanie nie powiodło się. Spróbuj ponownie." }, "toast": { - "uploaded": "Dokument zapisany.", - "uploadFailed": "Nie udało się zapisać dokumentu.", - "confirmed": "Zatwierdzone dane zapisane.", - "confirmedPartial": "Zapisano, ale {count} nie udało się zapisać — {reason}.", - "confirmFailed": "Nie udało się zapisać Twoich decyzji.", - "saved": "Dane zaktualizowane.", - "discarded": "Dokument odrzucony.", - "extracted": "Fakty wyodrębnione. Przejrzyj je poniżej.", - "extractFailed": "Nie udało się wyodrębnić faktów.", - "localOcrDisabled": "Włącz lokalne OCR w ustawieniach, aby odczytać ten dokument na urządzeniu.", - "updated": "Dokument zaktualizowany.", - "updateFailed": "Nie udało się zaktualizować dokumentu.", - "discardFailed": "Nie udało się odrzucić dokumentu.", - "uploadTooLarge": "Plik jest za duży (maks. 12 MB).", - "uploadFileType": "Prześlij plik JPEG, PNG, WebP lub PDF.", - "uploadRateLimited": "Zbyt wiele przesłań. Spróbuj ponownie później.", - "uploadInvalidMetadata": "Sprawdź tytuł, kategorię lub datę i spróbuj ponownie." + "duplicate": "Już zapisano — istniejący dokument został wyróżniony.", + "deleted": "Dokument usunięty.", + "restoreFailed": "Nie udało się przywrócić dokumentu." }, - "toolbar": { - "searchLabel": "Szukaj", - "searchPlaceholder": "Tytuł lub nazwa pliku", - "kindFilterLabel": "Kategoria", - "allKinds": "Wszystkie kategorie", - "sortLabel": "Sortuj według", - "sort": { - "documentDate": "Data dokumentu", - "createdAt": "Data dodania", - "title": "Tytuł" - }, - "orderAsc": "Rosnąco", - "orderDesc": "Malejąco", - "fromLabel": "Od", - "toLabel": "Do" + "timeline": { + "loadingMore": "Wczytywanie…" + }, + "upload": { + "zoneTitle": "Upuść pliki tutaj lub wybierz z urządzenia", + "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}" + }, + "selection": { + "count": "Zaznaczono: {count}", + "clear": "Wyczyść zaznaczenie" + }, + "list": { + "loadError": "Nie udało się wczytać Twoich dokumentów." + }, + "empty": { + "title": "Twój sejf dokumentów jest pusty", + "description": "Przechowuj pisma lekarskie, wyniki laboratoryjne, obrazowanie, recepty i zaświadczenia o szczepieniach — zaszyfrowane i przeszukiwalne według typu, schorzenia i roku.", + "action": "Prześlij pierwszy dokument", + "noMatchesTitle": "Brak pasujących dokumentów", + "noMatchesDescription": "Nic nie pasuje do bieżącego wyszukiwania i filtrów." }, "detail": { - "viewOriginal": "Zobacz oryginał", - "edit": "Edytuj", - "extract": "Wyodrębnij fakty", - "extracting": "Wyodrębnianie…", - "discard": "Odrzuć", - "notExtracted": "Nie wyodrębniono jeszcze faktów. Użyj Wyodrębnij fakty, aby odczytać ten dokument.", - "viewOriginalNewTab": "(otwiera się w nowej karcie)", - "discardConfirmTitle": "Odrzucić ten dokument?", - "discardConfirmBody": "Spowoduje to trwałe usunięcie dokumentu, oryginalnego pliku i przygotowanych danych. Tej operacji nie można cofnąć.", - "discardConfirmAction": "Odrzuć", - "discardCancel": "Anuluj" - }, - "edit": { - "title": "Edytuj dokument", - "subtitle": "Zmień nazwę, kategorię lub ustaw datę dokumentu.", + "title": "Szczegóły dokumentu", + "loadError": "Nie udało się wczytać tego dokumentu.", + "saveError": "Nie udało się zapisać zmiany.", + "deleteError": "Nie udało się usunąć dokumentu.", + "download": "Pobierz", + "delete": "Usuń", "titleLabel": "Tytuł", - "kindLabel": "Kategoria", + "titlePlaceholder": "Dodaj tytuł", + "kindLabel": "Typ", "dateLabel": "Data dokumentu", - "save": "Zapisz", - "saving": "Zapisywanie…", - "cancel": "Anuluj" - }, - "note": { - "providerUnsupported": "Skonfiguruj dostawcę skanowania dokumentów w ustawieniach AI, aby automatycznie wyodrębniać fakty.", - "alreadyConfirmed": "Ten dokument został już sprawdzony, więc nie można go ponownie wyodrębnić." - }, - "committed": { - "title": "Zatwierdzone z tego dokumentu" + "conditionsLabel": "Powiązane schorzenia", + "linkCondition": "Powiąż", + "noConditions": "Niepowiązany ze schorzeniem", + "previewUnavailable": "Brak podglądu dla tego typu pliku", + "uploadedMeta": "Dodano {date} · {size}" } }, "selfDescription": { diff --git a/src/app/documents/page.tsx b/src/app/documents/page.tsx index 553c73770..719bb37a7 100644 --- a/src/app/documents/page.tsx +++ b/src/app/documents/page.tsx @@ -1,45 +1,18 @@ -"use client"; +import { Suspense } from "react"; -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { Loader2 } from "lucide-react"; - -import { useAuth } from "@/hooks/use-auth"; -import { InboundDocumentsView } from "@/components/documents/inbound-documents-view"; +import { DocumentsView } from "@/components/documents/documents-view"; /** - * v1.25.0 (W-DOCS-IN) — inbound clinical documents entry. - * - * Born-gated on the resolved `modules.inboundDocuments` flag from - * `GET /api/auth/me` (opt-in / default-off — the per-user opt-in AND the - * operator server-wide kill-switch). An unauthenticated visitor is bounced to - * login; an authenticated account without the module opted in is bounced home - * (the nav entry is already hidden for them, so this only catches a direct URL - * hit). Every `/api/documents/inbound/*` route also enforces the gate - * server-side, so this is a UX redirect, not the security boundary. + * The Dokumente vault — server shell only. The client surface owns the + * module gate, the URL-backed filter state (which needs `useSearchParams`, + * hence the Suspense boundary), the virtualized timeline, and the upload + * queue. The route rides the AuthShell's wide-container flag: the shell + * owns the page frame, this file never re-implements paddings. */ export default function DocumentsPage() { - const { user, isLoading, isAuthenticated } = useAuth(); - const router = useRouter(); - - const enabled = user?.modules?.inboundDocuments === true; - - useEffect(() => { - if (isLoading) return; - if (!isAuthenticated) { - router.push("/auth/login"); - } else if (!enabled) { - router.push("/"); - } - }, [isLoading, isAuthenticated, enabled, router]); - - if (isLoading || !isAuthenticated || !enabled) { - return ( -
- -
- ); - } - - return ; + return ( + + + + ); } diff --git a/src/components/documents/__tests__/documents-view-states.test.tsx b/src/components/documents/__tests__/documents-view-states.test.tsx new file mode 100644 index 000000000..0bfbd899d --- /dev/null +++ b/src/components/documents/__tests__/documents-view-states.test.tsx @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +/** + * The vault page's designed states, pinned via single-pass static renders: + * + * - first load paints the skeleton grid (`documents-loading`) — a shape + * that matches the loaded card grid, never a bare spinner; + * - an empty unfiltered corpus paints the TEACHING empty state (what + * belongs here + an upload affordance), not the error copy; + * - an empty FILTERED view paints the no-matches state with a + * clear-filters affordance — a distinct state, per the standards a + * failed query may never fall through to either. + */ + +let mockSearch = ""; +const pushMock = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: pushMock, replace: vi.fn() }), + usePathname: () => "/documents", + useSearchParams: () => new URLSearchParams(mockSearch), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", modules: { inboundDocuments: true } }, + isLoading: false, + isAuthenticated: true, + }), +})); + +vi.mock("@/lib/api/api-fetch", async () => { + const actual = await vi.importActual( + "@/lib/api/api-fetch", + ); + return { + ...actual, + // Never resolves within the synchronous SSR render → queries whose + // cache is not seeded stay pending. + apiGet: () => new Promise(() => {}), + apiPatch: vi.fn(), + apiPost: vi.fn(), + apiDelete: vi.fn(), + }; +}); + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { DocumentsView } from "../documents-view"; + +function render(search: string, seedList?: { kinds?: never } | object) { + mockSearch = search; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + if (seedList !== undefined) { + queryClient.setQueryData(queryKeys.inboundDocumentList(seedList), { + pages: [{ documents: [], nextCursor: null }], + pageParams: [null], + }); + } + return renderToStaticMarkup( + + + + + , + ); +} + +describe(" states", () => { + it("first load: skeleton grid matching the card layout, no spinner", () => { + const html = render(""); + expect(html).toContain('data-slot="documents-loading"'); + expect(html).toContain('data-slot="skeleton"'); + expect(html).toContain('data-slot="document-filter-bar"'); + expect(html).toContain('data-slot="document-upload-zone"'); + // A pending list must never read as an empty vault. + expect(html).not.toContain("Your document vault is empty"); + }); + + it("empty unfiltered corpus: the teaching empty state with an upload CTA", () => { + const html = render("", {}); + expect(html).toContain('data-slot="empty-state"'); + expect(html).toContain("Your document vault is empty"); + expect(html).toContain("Upload a first document"); + expect(html).not.toContain("No matching documents"); + }); + + it("empty filtered view: the distinct no-matches state with clear-filters", () => { + const html = render("kind=IMAGING", { kinds: ["IMAGING"] }); + expect(html).toContain("No matching documents"); + expect(html).toContain("Clear filters"); + expect(html).not.toContain("Your document vault is empty"); + }); +}); diff --git a/src/components/documents/document-filter-bar.tsx b/src/components/documents/document-filter-bar.tsx new file mode 100644 index 000000000..b0e877829 --- /dev/null +++ b/src/components/documents/document-filter-bar.tsx @@ -0,0 +1,202 @@ +"use client"; + +/** + * The vault's sticky filter rail: debounced title/filename search, the + * nine-kind type-chip row (multi-select, OR inside the facet), condition + * chips (episodes that actually carry links), a year segmenter (years + * present in the corpus), and a one-tap clear with the active-facet count. + * + * Purely presentational — the filter state lives in the page URL and is + * owned by `documents-view.tsx`; this bar only renders the controls. It + * sticks below the top edge inside the shell's scroll container (sticky, + * translucent background, border-b — no viewport-height tricks). + */ +import { Search, X } from "lucide-react"; +import type { RefObject } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +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"; + +/** Shared chip chrome — mirrors the app-wide FilterBar pill vocabulary. */ +const CHIP_CLASSES = + "border-border bg-card text-foreground inline-flex min-h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-sm whitespace-nowrap shadow-xs transition-colors focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none"; +const CHIP_ACTIVE_CLASSES = "border-primary/40 bg-primary/10"; + +export interface ConditionChip { + episodeId: string; + name: string; +} + +export function DocumentFilterBar({ + searchValue, + onSearchChange, + searchInputRef, + activeKinds, + onToggleKind, + conditionChips, + activeEpisodeId, + onToggleEpisode, + years, + activeYear, + onToggleYear, + activeCount, + onClearAll, +}: { + searchValue: string; + onSearchChange: (value: string) => void; + /** Focused by the page-level `/` shortcut. */ + searchInputRef: RefObject; + activeKinds: ReadonlySet; + onToggleKind: (kind: InboundDocumentKindValue) => void; + conditionChips: ConditionChip[]; + activeEpisodeId: string | undefined; + onToggleEpisode: (episodeId: string) => void; + years: number[]; + activeYear: number | undefined; + onToggleYear: (year: number) => void; + activeCount: number; + onClearAll: () => void; +}) { + const { t } = useTranslations(); + + return ( +
+
+
+ + onSearchChange(e.target.value)} + placeholder={t("documents.filter.searchPlaceholder")} + aria-label={t("documents.filter.searchLabel")} + className="pr-8 pl-9" + aria-keyshortcuts="/" + /> + + / + +
+ {activeCount > 0 ? ( + + ) : null} +
+ +
+ + {DOCUMENT_KIND_ORDER.map((kind) => { + const Icon = DOCUMENT_KIND_ICONS[kind]; + const active = activeKinds.has(kind); + return ( + + ); + })} + + + {conditionChips.length > 0 ? ( + <> + + + {conditionChips.map((chip) => { + const active = chip.episodeId === activeEpisodeId; + return ( + + ); + })} + + + ) : null} + + {years.length > 0 ? ( + <> + + + {years.map((year) => { + const active = year === activeYear; + return ( + + ); + })} + + + ) : null} +
+
+ ); +} diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx new file mode 100644 index 000000000..b47c9a238 --- /dev/null +++ b/src/components/documents/documents-view.tsx @@ -0,0 +1,414 @@ +"use client"; + +/** + * The Dokumente vault — one wide, calm, filing-cabinet-fast surface. + * + * Filter state lives in the URL (`?q&kind&episode&year`) so every view is + * shareable, back-button-safe, and deep-linkable from the illness page and + * labs. Search debounces 200 ms and `/` focuses it. The timeline below is + * virtualized; uploads appear optimistically above it in < 100 ms. + * + * Born-gated on the resolved `modules.inboundDocuments` flag from + * `GET /api/auth/me` (per-user opt-in AND the operator availability layer). + * An unauthenticated visitor bounces to login; an account without the + * module bounces home. Every `/api/documents/inbound/*` route re-enforces + * the gate server-side — this is a UX redirect, not the security boundary. + */ +import { + useInfiniteQuery, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { FolderOpen, Loader2, SearchX, Upload, X } from "lucide-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { useIllnessEpisodes } from "@/components/illness/use-illness"; +import { Button } from "@/components/ui/button"; +import { EmptyState } from "@/components/ui/empty-state"; +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 { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { DocumentVaultFilters } from "@/lib/query-keys/documents"; +import type { + DocumentUsageDto, + InboundDocumentDetailDto, + InboundDocumentDto, + InboundDocumentKindValue, +} from "@/lib/validations/inbound-documents"; +import { DocumentDetailSheet } from "./document-detail-sheet"; +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 { + buildVaultListApiSearch, + countActiveFilters, + documentDateKey, + parseVaultSearchParams, + vaultFiltersToSearch, +} from "./vault-utils"; + +interface ListPage { + documents: InboundDocumentDto[]; + nextCursor: string | null; +} + +export function DocumentsView() { + const { t } = useTranslations(); + const { user, isLoading: authLoading, isAuthenticated } = useAuth(); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const queryClient = useQueryClient(); + + const moduleEnabled = user?.modules?.inboundDocuments === true; + + // UX redirect only — the API routes enforce the gate server-side. + useEffect(() => { + if (authLoading) return; + if (!isAuthenticated) { + router.push("/auth/login"); + } else if (!moduleEnabled) { + router.push("/"); + } + }, [authLoading, isAuthenticated, moduleEnabled, router]); + + // ── URL-owned filter state ──────────────────────────────────────────── + const filters = useMemo( + () => parseVaultSearchParams(new URLSearchParams(searchParams.toString())), + [searchParams], + ); + + const applyFilters = useCallback( + (next: DocumentVaultFilters, mode: "push" | "replace") => { + const search = vaultFiltersToSearch(next); + const href = search ? `${pathname}?${search}` : pathname; + if (mode === "push") { + router.push(href, { scroll: false }); + } else { + router.replace(href, { scroll: false }); + } + }, + [pathname, router], + ); + + // Search draft debounces into the URL (200 ms); an external URL change + // (back button, deep link) re-seeds the draft — render-phase derived-state + // adjustment, not an effect. + const [searchDraft, setSearchDraft] = useState(filters.q ?? ""); + const [lastUrlQ, setLastUrlQ] = useState(filters.q ?? ""); + const searchInputRef = useRef(null); + if ((filters.q ?? "") !== lastUrlQ) { + setLastUrlQ(filters.q ?? ""); + setSearchDraft(filters.q ?? ""); + } + useEffect(() => { + const handle = setTimeout(() => { + const trimmed = searchDraft.trim(); + if (trimmed === (filters.q ?? "")) return; + applyFilters( + { ...filters, q: trimmed === "" ? undefined : trimmed }, + "replace", + ); + }, 200); + return () => clearTimeout(handle); + }, [searchDraft, filters, applyFilters]); + + // `/` focuses the search from anywhere on the page. + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) + return; + const target = event.target as HTMLElement | null; + if ( + target && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.tagName === "SELECT" || + target.isContentEditable) + ) { + return; + } + event.preventDefault(); + searchInputRef.current?.focus(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + + const toggleKind = (kind: InboundDocumentKindValue) => { + const current = new Set(filters.kinds ?? []); + if (current.has(kind)) { + current.delete(kind); + } else { + current.add(kind); + } + applyFilters( + { + ...filters, + kinds: current.size > 0 ? [...current].sort() : undefined, + }, + "push", + ); + }; + + const toggleEpisode = (episodeId: string) => { + applyFilters( + { + ...filters, + episodeId: filters.episodeId === episodeId ? undefined : episodeId, + }, + "push", + ); + }; + + const toggleYear = (year: number) => { + applyFilters( + { ...filters, year: filters.year === year ? undefined : year }, + "push", + ); + }; + + const clearFilters = () => { + setSearchDraft(""); + applyFilters({}, "push"); + }; + + // ── Data ────────────────────────────────────────────────────────────── + const usage = useQuery({ + queryKey: queryKeys.inboundDocumentUsage(), + enabled: moduleEnabled, + queryFn: () => apiGet("/api/documents/inbound/usage"), + }); + + const list = useInfiniteQuery({ + queryKey: queryKeys.inboundDocumentList(filters), + enabled: moduleEnabled, + queryFn: ({ pageParam }) => + apiGet( + `/api/documents/inbound?${buildVaultListApiSearch(filters, pageParam)}`, + ), + initialPageParam: null as string | null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }); + + const documents = useMemo( + () => list.data?.pages.flatMap((p) => p.documents) ?? [], + [list.data], + ); + + const upload = useDocumentUpload( + usage.data ? { maxFileBytes: usage.data.maxFileBytes } : undefined, + ); + const uploadInputRef = useRef(null); + + const episodes = useIllnessEpisodes(true); + + // Condition chips: the user's episodes that actually carry links in the + // loaded corpus — plus the actively filtered episode (a deep link must + // always show its own chip, even before its documents load). + const conditionChips = useMemo(() => { + const byId = new Map(); + for (const doc of documents) { + for (const link of doc.conditionLinks) { + if (!byId.has(link.episodeId)) byId.set(link.episodeId, link.name); + } + } + if (filters.episodeId && !byId.has(filters.episodeId)) { + const episode = episodes.data?.find((e) => e.id === filters.episodeId); + byId.set(filters.episodeId, episode?.label ?? filters.episodeId); + } + return [...byId.entries()] + .map(([episodeId, name]) => ({ episodeId, name })) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [documents, filters.episodeId, episodes.data]); + + // Year segmenter: years present in the loaded corpus (+ the active year). + const years = useMemo(() => { + const set = new Set(); + for (const doc of documents) { + set.add(Number(documentDateKey(doc).slice(0, 4))); + } + if (filters.year !== undefined) set.add(filters.year); + return [...set].sort((a, b) => b - a); + }, [documents, filters.year]); + + // ── Selection (bulk actions attach to this in a follow-up) ─────────── + const [selectedIds, setSelectedIds] = useState>( + 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; + }); + }, []); + + // ── Detail sheet ────────────────────────────────────────────────────── + const [detailId, setDetailId] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + const openDetail = useCallback((id: string) => { + setDetailId(id); + setDetailOpen(true); + }, []); + + // Hover/focus intent prefetches the detail METADATA (never the blob — + // the blob fetch starts when the sheet mounts its preview element). + const prefetchDetail = useCallback( + (id: string) => { + void queryClient.prefetchQuery({ + queryKey: queryKeys.inboundDocument(id), + queryFn: () => + apiGet(`/api/documents/inbound/${id}`), + staleTime: 30_000, + }); + }, + [queryClient], + ); + + if (authLoading || !isAuthenticated || !moduleEnabled) { + return ( +
+ + {t("nav.loadingScreen")} +
+ ); + } + + const activeCount = countActiveFilters(filters); + const isFiltered = activeCount > 0; + const showEmpty = + list.isSuccess && documents.length === 0 && upload.items.length === 0; + + return ( +
+ uploadInputRef.current?.click()}> + + {t("documents.pageUpload")} + + } + /> + + + upload.enqueue(files, { episodeId: filters.episodeId }) + } + /> + + + + {selectedIds.size > 0 ? ( +
+

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

+ +
+ ) : null} + + {list.isPending ? ( +
+ {Array.from({ length: 9 }, (_, i) => ( + + ))} +
+ ) : list.isError ? ( + void list.refetch()} + /> + ) : showEmpty && !isFiltered ? ( + } + title={t("documents.empty.title")} + description={t("documents.empty.description")} + ctaSize="lg" + action={ + + } + /> + ) : showEmpty && isFiltered ? ( + } + title={t("documents.empty.noMatchesTitle")} + description={t("documents.empty.noMatchesDescription")} + action={ + + } + /> + ) : ( + void list.fetchNextPage()} + selectedIds={selectedIds} + onToggleSelected={toggleSelected} + onOpen={openDetail} + highlightId={upload.highlightId} + onPrefetch={prefetchDetail} + /> + )} + + +
+ ); +} diff --git a/src/components/documents/upload-zone.tsx b/src/components/documents/upload-zone.tsx new file mode 100644 index 000000000..91c4ba1e8 --- /dev/null +++ b/src/components/documents/upload-zone.tsx @@ -0,0 +1,142 @@ +"use client"; + +/** + * The vault's upload entry: a dashed drop-target card with a file button. + * The `accept` list comes verbatim from the usage endpoint (HEIC is + * deliberately absent so the iOS picker transcodes camera photos to JPEG), + * `multiple` is on, and there is NO `capture` attribute — mobile Safari + * then offers camera AND library. Dropping files onto the card feeds the + * same queue as the picker. + * + * The quota bar renders only above 80 % usage — a calm surface until + * storage actually becomes a topic. + */ +import { UploadCloud } from "lucide-react"; +import { useRef, useState, type RefObject } from "react"; + +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import type { DocumentUsageDto } from "@/lib/validations/inbound-documents"; +import { formatBytes } from "./vault-utils"; + +/** Usage fraction above which the quota bar appears. */ +const QUOTA_BAR_THRESHOLD = 0.8; + +export function UploadZone({ + usage, + onFiles, + inputRef, +}: { + /** Limits + usage from `GET /api/documents/inbound/usage` (undefined while loading). */ + usage: DocumentUsageDto | undefined; + onFiles: (files: File[]) => void; + /** Exposed so the page header's Upload button can open the same picker. */ + inputRef: RefObject; +}) { + const { t, locale } = useTranslations(); + const [dragOver, setDragOver] = useState(false); + const dragDepth = useRef(0); + + const accept = usage?.acceptedExtensions.join(","); + const usedFraction = + usage && usage.quotaBytes > 0 ? usage.usedBytes / usage.quotaBytes : 0; + + const pickFiles = (list: FileList | null) => { + if (!list || list.length === 0) return; + onFiles(Array.from(list)); + }; + + return ( +
+
{ + e.preventDefault(); + dragDepth.current += 1; + setDragOver(true); + }} + onDragOver={(e) => e.preventDefault()} + onDragLeave={() => { + dragDepth.current = Math.max(0, dragDepth.current - 1); + if (dragDepth.current === 0) setDragOver(false); + }} + onDrop={(e) => { + e.preventDefault(); + dragDepth.current = 0; + setDragOver(false); + pickFiles(e.dataTransfer.files); + }} + className={cn( + "border-border rounded-xl border-2 border-dashed px-4 py-6 text-center transition-colors", + dragOver && "border-primary bg-primary/5", + )} + > +
+ +

+ {t("documents.upload.zoneTitle")} +

+ {usage ? ( +

+ {t("documents.upload.zoneHint", { + maxSize: formatBytes(usage.maxFileBytes, locale), + })} +

+ ) : null} + +
+ { + pickFiles(e.target.files); + // Allow re-selecting the same file (duplicate flow) immediately. + e.target.value = ""; + }} + /> +
+ + {usage && usedFraction >= QUOTA_BAR_THRESHOLD ? ( +
+
+

+ {t("documents.upload.quotaAlmostFull")} +

+

+ {t("documents.upload.quotaUsed", { + used: formatBytes(usage.usedBytes, locale), + quota: formatBytes(usage.quotaBytes, locale), + })} +

+
+ +
+ ) : null} +
+ ); +} diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 829a742ce..af0a451a5 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -36,6 +36,13 @@ const PUBLIC_PATHS = [ "/c/", ]; +// Routes that opt into the wide content container (`max-w-screen-2xl` +// instead of the standard `max-w-screen-xl`). The shell stays the single +// owner of the page frame — a wide page never re-implements paddings or +// margins; it is listed here instead. Currently: the Dokumente vault, +// whose multi-column document timeline earns the extra width. +const WIDE_CONTENT_PATHS = ["/documents"]; + export function AuthShell({ children, demoMode = false, @@ -69,6 +76,9 @@ export function AuthShell({ pathname.startsWith("/about") || pathname.startsWith("/c/"); const isAdminPage = pathname.startsWith("/admin"); + const isWidePage = WIDE_CONTENT_PATHS.some( + (p) => pathname === p || pathname.startsWith(`${p}/`), + ); const isOnboardingPage = pathname === "/onboarding"; const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id; @@ -315,7 +325,13 @@ export function AuthShell({ the desktop bottom-6 anchor), so the last line of content can always scroll out from under the floating button. */} -
+
{children}