From 72fca9206641b16071cf88008f8d9d8248a5c055 Mon Sep 17 00:00:00 2001 From: Rafael Foster Date: Wed, 29 Apr 2026 06:14:29 -0300 Subject: [PATCH] feat(documents): add family document management --- public/locales/ar.json | 63 +++++- public/locales/de.json | 63 +++++- public/locales/el.json | 63 +++++- public/locales/en.json | 61 +++++- public/locales/es.json | 63 +++++- public/locales/fr.json | 63 +++++- public/locales/hi.json | 63 +++++- public/locales/it.json | 63 +++++- public/locales/ja.json | 63 +++++- public/locales/pt.json | 61 +++++- public/locales/ru.json | 63 +++++- public/locales/sv.json | 63 +++++- public/locales/tr.json | 63 +++++- public/locales/uk.json | 63 +++++- public/locales/zh.json | 63 +++++- public/pages/documents.js | 377 ++++++++++++++++++++++++++++++++++++ public/router.js | 5 +- public/styles/documents.css | 279 ++++++++++++++++++++++++++ public/styles/tokens.css | 2 + public/sw.js | 10 +- server/db-schema-test.js | 39 ++++ server/db.js | 43 ++++ server/index.js | 2 + server/routes/documents.js | 262 +++++++++++++++++++++++++ 24 files changed, 1927 insertions(+), 33 deletions(-) create mode 100644 public/pages/documents.js create mode 100644 public/styles/documents.css create mode 100644 server/routes/documents.js diff --git a/public/locales/ar.json b/public/locales/ar.json index 5a2fafb..25ac6a0 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -46,7 +46,8 @@ "navigation": "التنقل", "quickActions": "الإجراءات السريعة", "recipes": "الوصفات", - "more": "المزيد" + "more": "المزيد", + "documents": "المستندات" }, "dashboard": { "title": "لوحة التحكم", @@ -897,5 +898,63 @@ }, "emptyHint": { "recipes": "أنشئ وصفات واربطها بمخطط الوجبات." + }, + "documents": { + "title": "المستندات", + "addButton": "إضافة مستند", + "searchPlaceholder": "البحث في المستندات...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "كل الفئات", + "emptyTitle": "لا توجد مستندات بعد", + "emptyDescription": "ارفع مستندات العائلة وتحكم في من يمكنه رؤية كل ملف.", + "newTitle": "مستند جديد", + "editTitle": "إعدادات المستند", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "كل العائلة", + "restricted": "أعضاء محددون", + "private": "أنا فقط" + }, + "category": { + "medical": "طبي", + "school": "مدرسة", + "identity": "هوية", + "insurance": "تأمين", + "finance": "مالية", + "home": "منزل", + "vehicle": "مركبة", + "legal": "قانوني", + "travel": "سفر", + "pets": "حيوانات أليفة", + "warranty": "ضمان", + "taxes": "ضرائب", + "work": "عمل", + "other": "أخرى" + } } -} \ No newline at end of file +} diff --git a/public/locales/de.json b/public/locales/de.json index 7a6e61e..01cb775 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -46,7 +46,8 @@ "navigation": "Navigation", "quickActions": "Schnellaktionen", "more": "Mehr", - "recipes": "Rezepte" + "recipes": "Rezepte", + "documents": "Dokumente" }, "search": { "title": "Suche", @@ -935,5 +936,63 @@ "goCal": "Kalender", "goShop": "Einkaufsliste", "goNotes": "Notizen" + }, + "documents": { + "title": "Dokumente", + "addButton": "Dokument hinzufügen", + "searchPlaceholder": "Dokumente suchen...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "Alle Kategorien", + "emptyTitle": "Noch keine Dokumente", + "emptyDescription": "Lade Familiendokumente hoch und steuere, wer jede Datei sehen darf.", + "newTitle": "Neues Dokument", + "editTitle": "Dokumenteinstellungen", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "Ganze Familie", + "restricted": "Ausgewählte Mitglieder", + "private": "Nur ich" + }, + "category": { + "medical": "Medizin", + "school": "Schule", + "identity": "Identität", + "insurance": "Versicherung", + "finance": "Finanzen", + "home": "Zuhause", + "vehicle": "Fahrzeug", + "legal": "Rechtliches", + "travel": "Reisen", + "pets": "Haustiere", + "warranty": "Garantie", + "taxes": "Steuern", + "work": "Arbeit", + "other": "Sonstiges" + } } -} \ No newline at end of file +} diff --git a/public/locales/el.json b/public/locales/el.json index 612be4a..434d361 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -46,7 +46,8 @@ "navigation": "Πλοήγηση", "quickActions": "Γρήγορες ενέργειες", "recipes": "Συνταγές", - "more": "Περισσότερα" + "more": "Περισσότερα", + "documents": "Έγγραφα" }, "dashboard": { "title": "Επισκόπηση", @@ -897,5 +898,63 @@ }, "emptyHint": { "recipes": "Δημιουργήστε συνταγές και συνδέστε τις με τον προγραμματισμό γευμάτων." + }, + "documents": { + "title": "Έγγραφα", + "addButton": "Προσθήκη εγγράφου", + "searchPlaceholder": "Αναζήτηση εγγράφων...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "Όλες οι κατηγορίες", + "emptyTitle": "Δεν υπάρχουν έγγραφα ακόμα", + "emptyDescription": "Ανεβάστε οικογενειακά έγγραφα και ελέγξτε ποιος μπορεί να βλέπει κάθε αρχείο.", + "newTitle": "Νέο έγγραφο", + "editTitle": "Ρυθμίσεις εγγράφου", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "Όλη η οικογένεια", + "restricted": "Επιλεγμένα μέλη", + "private": "Μόνο εγώ" + }, + "category": { + "medical": "Ιατρικά", + "school": "Σχολείο", + "identity": "Ταυτότητα", + "insurance": "Ασφάλιση", + "finance": "Οικονομικά", + "home": "Σπίτι", + "vehicle": "Όχημα", + "legal": "Νομικά", + "travel": "Ταξίδια", + "pets": "Κατοικίδια", + "warranty": "Εγγύηση", + "taxes": "Φόροι", + "work": "Εργασία", + "other": "Άλλο" + } } -} \ No newline at end of file +} diff --git a/public/locales/en.json b/public/locales/en.json index 29f870b..de7002a 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -46,7 +46,8 @@ "navigation": "Navigation", "quickActions": "Quick actions", "recipes": "Recipes", - "more": "More" + "more": "More", + "documents": "Documents" }, "dashboard": { "title": "Overview", @@ -921,5 +922,63 @@ "meals": "Plan meals for the week and link recipes.", "birthdays": "Add birthdays — you will receive a reminder in time.", "recipes": "Create recipes and link them to your meal planner." + }, + "documents": { + "title": "Documents", + "addButton": "Add document", + "searchPlaceholder": "Search documents...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "All categories", + "emptyTitle": "No documents yet", + "emptyDescription": "Upload family documents and control who can see each file.", + "newTitle": "New document", + "editTitle": "Document settings", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "Entire family", + "restricted": "Selected members", + "private": "Only me" + }, + "category": { + "medical": "Medical", + "school": "School", + "identity": "Identity", + "insurance": "Insurance", + "finance": "Finance", + "home": "Home", + "vehicle": "Vehicle", + "legal": "Legal", + "travel": "Travel", + "pets": "Pets", + "warranty": "Warranty", + "taxes": "Taxes", + "work": "Work", + "other": "Other" + } } } diff --git a/public/locales/es.json b/public/locales/es.json index 2be26bd..b75d023 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -46,7 +46,8 @@ "navigation": "Navegación", "quickActions": "Acciones rápidas", "recipes": "Recetas", - "more": "Más" + "more": "Más", + "documents": "Documentos" }, "dashboard": { "title": "Inicio", @@ -897,5 +898,63 @@ }, "emptyHint": { "recipes": "Crea recetas y vincúlalas con tu planificador de comidas." + }, + "documents": { + "title": "Documentos", + "addButton": "Agregar documento", + "searchPlaceholder": "Buscar documentos...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "Todas las categorías", + "emptyTitle": "Aún no hay documentos", + "emptyDescription": "Sube documentos familiares y controla quién puede ver cada archivo.", + "newTitle": "Nuevo documento", + "editTitle": "Configuración del documento", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "Toda la familia", + "restricted": "Miembros seleccionados", + "private": "Solo yo" + }, + "category": { + "medical": "Médico", + "school": "Escuela", + "identity": "Identidad", + "insurance": "Seguro", + "finance": "Finanzas", + "home": "Hogar", + "vehicle": "Vehículo", + "legal": "Legal", + "travel": "Viajes", + "pets": "Mascotas", + "warranty": "Garantía", + "taxes": "Impuestos", + "work": "Trabajo", + "other": "Otros" + } } -} \ No newline at end of file +} diff --git a/public/locales/fr.json b/public/locales/fr.json index bd2e99d..552f271 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -46,7 +46,8 @@ "navigation": "Navigation", "quickActions": "Actions rapides", "recipes": "Recettes", - "more": "Plus" + "more": "Plus", + "documents": "Documents" }, "dashboard": { "title": "Accueil", @@ -897,5 +898,63 @@ }, "emptyHint": { "recipes": "Créez des recettes et associez-les à votre planification des repas." + }, + "documents": { + "title": "Documents", + "addButton": "Ajouter un document", + "searchPlaceholder": "Rechercher des documents...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "Toutes les catégories", + "emptyTitle": "Aucun document pour le moment", + "emptyDescription": "Ajoutez des documents familiaux et contrôlez qui peut voir chaque fichier.", + "newTitle": "Nouveau document", + "editTitle": "Paramètres du document", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "Toute la famille", + "restricted": "Membres sélectionnés", + "private": "Moi uniquement" + }, + "category": { + "medical": "Médical", + "school": "École", + "identity": "Identité", + "insurance": "Assurance", + "finance": "Finances", + "home": "Maison", + "vehicle": "Véhicule", + "legal": "Juridique", + "travel": "Voyage", + "pets": "Animaux", + "warranty": "Garantie", + "taxes": "Impôts", + "work": "Travail", + "other": "Autre" + } } -} \ No newline at end of file +} diff --git a/public/locales/hi.json b/public/locales/hi.json index 7f7bc16..6fd8ecc 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -46,7 +46,8 @@ "navigation": "नेविगेशन", "quickActions": "त्वरित क्रियाएं", "recipes": "रेसिपी", - "more": "और" + "more": "और", + "documents": "दस्तावेज़" }, "dashboard": { "title": "डैशबोर्ड", @@ -897,5 +898,63 @@ }, "emptyHint": { "recipes": "रेसिपी बनाएं और उन्हें अपने भोजन योजनाकार से जोड़ें।" + }, + "documents": { + "title": "दस्तावेज़", + "addButton": "दस्तावेज़ जोड़ें", + "searchPlaceholder": "दस्तावेज़ खोजें...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "सभी श्रेणियाँ", + "emptyTitle": "अभी कोई दस्तावेज़ नहीं", + "emptyDescription": "परिवार के दस्तावेज़ अपलोड करें और तय करें कि हर फ़ाइल कौन देख सकता है।", + "newTitle": "नया दस्तावेज़", + "editTitle": "दस्तावेज़ सेटिंग्स", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "पूरा परिवार", + "restricted": "चुने हुए सदस्य", + "private": "केवल मैं" + }, + "category": { + "medical": "चिकित्सा", + "school": "स्कूल", + "identity": "पहचान", + "insurance": "बीमा", + "finance": "वित्त", + "home": "घर", + "vehicle": "वाहन", + "legal": "कानूनी", + "travel": "यात्रा", + "pets": "पालतू", + "warranty": "वारंटी", + "taxes": "कर", + "work": "काम", + "other": "अन्य" + } } -} \ No newline at end of file +} diff --git a/public/locales/it.json b/public/locales/it.json index 2dff300..76aa5a9 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -46,7 +46,8 @@ "navigation": "Navigazione", "quickActions": "Azioni rapide", "recipes": "Ricette", - "more": "Altro" + "more": "Altro", + "documents": "Documenti" }, "dashboard": { "title": "Panoramica", @@ -897,5 +898,63 @@ }, "emptyHint": { "recipes": "Crea ricette e collegale al tuo piano pasti." + }, + "documents": { + "title": "Documenti", + "addButton": "Aggiungi documento", + "searchPlaceholder": "Cerca documenti...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "Tutte le categorie", + "emptyTitle": "Nessun documento", + "emptyDescription": "Carica documenti di famiglia e controlla chi può vedere ogni file.", + "newTitle": "Nuovo documento", + "editTitle": "Impostazioni documento", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "Tutta la famiglia", + "restricted": "Membri selezionati", + "private": "Solo io" + }, + "category": { + "medical": "Medico", + "school": "Scuola", + "identity": "Identità", + "insurance": "Assicurazione", + "finance": "Finanze", + "home": "Casa", + "vehicle": "Veicolo", + "legal": "Legale", + "travel": "Viaggi", + "pets": "Animali", + "warranty": "Garanzia", + "taxes": "Tasse", + "work": "Lavoro", + "other": "Altro" + } } -} \ No newline at end of file +} diff --git a/public/locales/ja.json b/public/locales/ja.json index c983173..0f952a0 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -46,7 +46,8 @@ "navigation": "ナビゲーション", "quickActions": "クイックアクション", "recipes": "レシピ", - "more": "もっと見る" + "more": "もっと見る", + "documents": "書類" }, "dashboard": { "title": "ダッシュボード", @@ -897,5 +898,63 @@ }, "emptyHint": { "recipes": "レシピを作成して、食事プランに関連付けましょう。" + }, + "documents": { + "title": "書類", + "addButton": "書類を追加", + "searchPlaceholder": "書類を検索...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "すべてのカテゴリ", + "emptyTitle": "書類はまだありません", + "emptyDescription": "家族の書類をアップロードし、各ファイルを見られるメンバーを管理できます。", + "newTitle": "新しい書類", + "editTitle": "書類設定", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "家族全員", + "restricted": "選択したメンバー", + "private": "自分のみ" + }, + "category": { + "medical": "医療", + "school": "学校", + "identity": "本人確認", + "insurance": "保険", + "finance": "金融", + "home": "家", + "vehicle": "車両", + "legal": "法務", + "travel": "旅行", + "pets": "ペット", + "warranty": "保証", + "taxes": "税金", + "work": "仕事", + "other": "その他" + } } -} \ No newline at end of file +} diff --git a/public/locales/pt.json b/public/locales/pt.json index d4db15c..e4c743a 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -46,7 +46,8 @@ "navigation": "Navegação", "quickActions": "Ações rápidas", "recipes": "Receitas", - "more": "Mais" + "more": "Mais", + "documents": "Documentos" }, "dashboard": { "title": "Painel", @@ -903,5 +904,63 @@ }, "emptyHint": { "recipes": "Crie receitas e vincule-as ao seu planejador de refeições." + }, + "documents": { + "title": "Documentos", + "addButton": "Adicionar documento", + "searchPlaceholder": "Buscar documentos...", + "gridView": "Visualizacao em grade", + "listView": "Visualizacao em lista", + "viewToggle": "Visualizacao de documentos", + "allCategories": "Todas as categorias", + "emptyTitle": "Nenhum documento ainda", + "emptyDescription": "Envie documentos da familia e controle quem pode ver cada arquivo.", + "newTitle": "Novo documento", + "editTitle": "Configuracoes do documento", + "nameLabel": "Nome", + "descriptionLabel": "Descricao", + "categoryLabel": "Categoria", + "fileLabel": "Arquivo", + "fileHint": "PDF, imagens, texto e arquivos Office ate 5 MB.", + "visibilityLabel": "Visibilidade", + "statusLabel": "Status", + "allowedMembersLabel": "Membros permitidos", + "uploadAction": "Enviar", + "downloadAction": "Baixar", + "editAction": "Configuracoes", + "archiveAction": "Arquivar", + "restoreAction": "Restaurar", + "savedToast": "Documento salvo.", + "uploadedToast": "Documento enviado.", + "archivedToast": "Documento arquivado.", + "restoredToast": "Documento restaurado.", + "deletedToast": "Documento excluido.", + "deleteConfirm": "Excluir documento \"{{name}}\"?", + "fileRequired": "Selecione um arquivo para enviar.", + "fileTooLarge": "O arquivo pode ter no maximo 5 MB.", + "fileReadError": "Nao foi possivel ler o arquivo.", + "statusActive": "Ativo", + "statusArchived": "Arquivado", + "visibility": { + "family": "Familia inteira", + "restricted": "Membros selecionados", + "private": "Somente eu" + }, + "category": { + "medical": "Medico", + "school": "Escola", + "identity": "Identidade", + "insurance": "Seguro", + "finance": "Financeiro", + "home": "Casa", + "vehicle": "Veiculo", + "legal": "Juridico", + "travel": "Viagem", + "pets": "Pets", + "warranty": "Garantia", + "taxes": "Impostos", + "work": "Trabalho", + "other": "Outros" + } } } diff --git a/public/locales/ru.json b/public/locales/ru.json index 2e5881a..bde9516 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -46,7 +46,8 @@ "navigation": "Навигация", "quickActions": "Быстрые действия", "recipes": "Рецепты", - "more": "Ещё" + "more": "Ещё", + "documents": "Документы" }, "dashboard": { "title": "Обзор", @@ -897,5 +898,63 @@ }, "emptyHint": { "recipes": "Создавайте рецепты и связывайте их с вашим планом питания." + }, + "documents": { + "title": "Документы", + "addButton": "Добавить документ", + "searchPlaceholder": "Поиск документов...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "Все категории", + "emptyTitle": "Документов пока нет", + "emptyDescription": "Загружайте семейные документы и управляйте доступом к каждому файлу.", + "newTitle": "Новый документ", + "editTitle": "Настройки документа", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "Вся семья", + "restricted": "Выбранные участники", + "private": "Только я" + }, + "category": { + "medical": "Медицина", + "school": "Школа", + "identity": "Удостоверения", + "insurance": "Страхование", + "finance": "Финансы", + "home": "Дом", + "vehicle": "Автомобиль", + "legal": "Юридическое", + "travel": "Путешествия", + "pets": "Питомцы", + "warranty": "Гарантия", + "taxes": "Налоги", + "work": "Работа", + "other": "Другое" + } } -} \ No newline at end of file +} diff --git a/public/locales/sv.json b/public/locales/sv.json index 342d0f0..ee548b6 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -46,7 +46,8 @@ "navigation": "Navigering", "quickActions": "Snabba åtgärder", "recipes": "Recept", - "more": "Mer" + "more": "Mer", + "documents": "Dokument" }, "dashboard": { "title": "Översikt", @@ -897,5 +898,63 @@ }, "emptyHint": { "recipes": "Skapa recept och koppla dem till din måltidsplanering." + }, + "documents": { + "title": "Dokument", + "addButton": "Lägg till dokument", + "searchPlaceholder": "Sök dokument...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "Alla kategorier", + "emptyTitle": "Inga dokument ännu", + "emptyDescription": "Ladda upp familjedokument och styr vem som kan se varje fil.", + "newTitle": "Nytt dokument", + "editTitle": "Dokumentinställningar", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "Hela familjen", + "restricted": "Valda medlemmar", + "private": "Endast jag" + }, + "category": { + "medical": "Medicinskt", + "school": "Skola", + "identity": "Identitet", + "insurance": "Försäkring", + "finance": "Ekonomi", + "home": "Hem", + "vehicle": "Fordon", + "legal": "Juridiskt", + "travel": "Resor", + "pets": "Husdjur", + "warranty": "Garanti", + "taxes": "Skatter", + "work": "Arbete", + "other": "Övrigt" + } } -} \ No newline at end of file +} diff --git a/public/locales/tr.json b/public/locales/tr.json index 6088b79..775d548 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -46,7 +46,8 @@ "navigation": "Gezinme", "quickActions": "Hızlı işlemler", "recipes": "Tarifler", - "more": "Daha Fazla" + "more": "Daha Fazla", + "documents": "Belgeler" }, "dashboard": { "title": "Genel Bakış", @@ -897,5 +898,63 @@ }, "emptyHint": { "recipes": "Tarifler oluşturun ve yemek planlayıcınıza bağlayın." + }, + "documents": { + "title": "Belgeler", + "addButton": "Belge ekle", + "searchPlaceholder": "Belgelerde ara...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "Tüm kategoriler", + "emptyTitle": "Henüz belge yok", + "emptyDescription": "Aile belgelerini yükleyin ve her dosyayı kimlerin görebileceğini yönetin.", + "newTitle": "Yeni belge", + "editTitle": "Belge ayarları", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "Tüm aile", + "restricted": "Seçili üyeler", + "private": "Sadece ben" + }, + "category": { + "medical": "Tıbbi", + "school": "Okul", + "identity": "Kimlik", + "insurance": "Sigorta", + "finance": "Finans", + "home": "Ev", + "vehicle": "Araç", + "legal": "Hukuki", + "travel": "Seyahat", + "pets": "Evcil hayvanlar", + "warranty": "Garanti", + "taxes": "Vergiler", + "work": "İş", + "other": "Diğer" + } } -} \ No newline at end of file +} diff --git a/public/locales/uk.json b/public/locales/uk.json index 5333a59..4c100a7 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -46,7 +46,8 @@ "navigation": "Навігація", "quickActions": "Швидкі дії", "recipes": "Рецепти", - "more": "Більше" + "more": "Більше", + "documents": "Документи" }, "dashboard": { "title": "Огляд", @@ -905,5 +906,63 @@ "meals": "Плануйте харчування на тиждень і пов'язуйте рецепти.", "birthdays": "Додайте дні народження — ви отримаєте нагадування завчасно.", "recipes": "Створюйте рецепти та пов'язуйте їх із планувальником харчування." + }, + "documents": { + "title": "Документи", + "addButton": "Додати документ", + "searchPlaceholder": "Пошук документів...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "Усі категорії", + "emptyTitle": "Документів ще немає", + "emptyDescription": "Завантажуйте сімейні документи та керуйте доступом до кожного файлу.", + "newTitle": "Новий документ", + "editTitle": "Налаштування документа", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "Уся сім’я", + "restricted": "Вибрані учасники", + "private": "Лише я" + }, + "category": { + "medical": "Медицина", + "school": "Школа", + "identity": "Посвідчення", + "insurance": "Страхування", + "finance": "Фінанси", + "home": "Дім", + "vehicle": "Авто", + "legal": "Юридичне", + "travel": "Подорожі", + "pets": "Тварини", + "warranty": "Гарантія", + "taxes": "Податки", + "work": "Робота", + "other": "Інше" + } } -} \ No newline at end of file +} diff --git a/public/locales/zh.json b/public/locales/zh.json index cf396f7..77645ce 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -46,7 +46,8 @@ "navigation": "导航", "quickActions": "快捷操作", "recipes": "食谱", - "more": "更多" + "more": "更多", + "documents": "文档" }, "dashboard": { "title": "概览", @@ -897,5 +898,63 @@ }, "emptyHint": { "recipes": "创建食谱并将其关联到你的膳食计划。" + }, + "documents": { + "title": "文档", + "addButton": "添加文档", + "searchPlaceholder": "搜索文档...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "所有类别", + "emptyTitle": "还没有文档", + "emptyDescription": "上传家庭文档并控制每个文件的可见成员。", + "newTitle": "新文档", + "editTitle": "文档设置", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "整个家庭", + "restricted": "选定成员", + "private": "仅我" + }, + "category": { + "medical": "医疗", + "school": "学校", + "identity": "身份", + "insurance": "保险", + "finance": "财务", + "home": "家庭", + "vehicle": "车辆", + "legal": "法律", + "travel": "旅行", + "pets": "宠物", + "warranty": "保修", + "taxes": "税务", + "work": "工作", + "other": "其他" + } } -} \ No newline at end of file +} diff --git a/public/pages/documents.js b/public/pages/documents.js new file mode 100644 index 0000000..464de01 --- /dev/null +++ b/public/pages/documents.js @@ -0,0 +1,377 @@ +/** + * Module: Family Documents + * Purpose: Grid/list document management with local uploads and member visibility. + * Dependencies: /api.js, shared modal, i18n + */ + +import { api } from '/api.js'; +import { openModal as openSharedModal, closeModal } from '/components/modal.js'; +import { t, formatDate } from '/i18n.js'; +import { esc } from '/utils/html.js'; +import { stagger } from '/utils/ux.js'; + +const CATEGORIES = ['medical', 'school', 'identity', 'insurance', 'finance', 'home', 'vehicle', 'legal', 'travel', 'pets', 'warranty', 'taxes', 'work', 'other']; +const MAX_FILE_SIZE = 5 * 1024 * 1024; + +const CATEGORY_ICONS = { + medical: 'heart-pulse', + school: 'graduation-cap', + identity: 'badge-check', + insurance: 'shield-check', + finance: 'landmark', + home: 'home', + vehicle: 'car', + legal: 'scale', + travel: 'plane', + pets: 'paw-print', + warranty: 'receipt', + taxes: 'file-spreadsheet', + work: 'briefcase-business', + other: 'folder', +}; + +function categoryLabels() { + return Object.fromEntries(CATEGORIES.map((category) => [category, t(`documents.category.${category}`)])); +} + +let state = { + documents: [], + members: [], + view: localStorage.getItem('oikos-documents-view') || 'grid', + status: 'active', + category: '', + query: '', +}; +let _container = null; + +export async function render(container) { + _container = container; + container.innerHTML = ` +
+
+

${t('documents.title')}

+ +
+ + +
+ +
+
+ + +
+
+ +
+ `; + + if (window.lucide) lucide.createIcons(); + + await Promise.all([loadMembers(), loadDocuments()]); + bindPageEvents(); + renderDocuments(); +} + +async function loadMembers() { + const res = await api.get('/family/members'); + state.members = res.data || []; +} + +async function loadDocuments() { + const params = new URLSearchParams(); + params.set('status', state.status); + if (state.category) params.set('category', state.category); + const res = await api.get(`/documents?${params.toString()}`); + state.documents = res.data || []; +} + +function bindPageEvents() { + _container.querySelector('#documents-add-btn')?.addEventListener('click', () => openDocumentModal()); + _container.querySelector('#fab-new-document')?.addEventListener('click', () => openDocumentModal()); + _container.querySelector('#documents-search')?.addEventListener('input', (e) => { + state.query = e.target.value.trim().toLowerCase(); + renderDocuments(); + }); + _container.querySelector('#documents-status')?.addEventListener('change', async (e) => { + state.status = e.target.value; + await loadDocuments(); + renderDocuments(); + }); + _container.querySelector('#documents-category')?.addEventListener('change', async (e) => { + state.category = e.target.value; + await loadDocuments(); + renderDocuments(); + }); + _container.querySelector('.documents-view-toggle')?.addEventListener('click', (e) => { + const btn = e.target.closest('[data-view]'); + if (!btn) return; + state.view = btn.dataset.view; + localStorage.setItem('oikos-documents-view', state.view); + _container.querySelectorAll('.documents-view-toggle__btn').forEach((el) => + el.classList.toggle('documents-view-toggle__btn--active', el === btn) + ); + renderDocuments(); + }); + _container.querySelector('#documents-list')?.addEventListener('click', handleDocumentAction); +} + +function filteredDocuments() { + if (!state.query) return state.documents; + return state.documents.filter((doc) => + doc.name.toLowerCase().includes(state.query) || + (doc.description || '').toLowerCase().includes(state.query) || + doc.original_name.toLowerCase().includes(state.query) + ); +} + +function renderDocuments() { + const list = _container.querySelector('#documents-list'); + if (!list) return; + const docs = filteredDocuments(); + list.className = `documents-list documents-list--${state.view}`; + if (!docs.length) { + list.innerHTML = ` +
+ +
${t('documents.emptyTitle')}
+
${t('documents.emptyDescription')}
+
+ `; + if (window.lucide) lucide.createIcons(); + return; + } + list.innerHTML = docs.map((doc) => state.view === 'list' ? renderListItem(doc) : renderGridCard(doc)).join(''); + if (window.lucide) lucide.createIcons(); + stagger(list.querySelectorAll('.document-card, .document-row')); +} + +function renderMeta(doc) { + const labels = categoryLabels(); + return ` + ${labels[doc.category] || doc.category} + ${t(`documents.visibility.${doc.visibility}`)} + ${formatFileSize(doc.file_size)} + `; +} + +function renderActions(doc) { + return ` + + + + + + + `; +} + +function renderGridCard(doc) { + return ` +
+
+
+

${esc(doc.name)}

+

${esc(doc.description || doc.original_name)}

+
${renderMeta(doc)}
+
+ +
+ `; +} + +function renderListItem(doc) { + return ` +
+
+
+

${esc(doc.name)}

+
${renderMeta(doc)}
+
+
${renderActions(doc)}
+
+ `; +} + +async function handleDocumentAction(e) { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + const doc = state.documents.find((item) => String(item.id) === String(btn.dataset.id)); + if (!doc) return; + if (btn.dataset.action === 'edit') openDocumentModal(doc); + if (btn.dataset.action === 'archive') { + await api.patch(`/documents/${doc.id}/archive`, { archived: doc.status !== 'archived' }); + window.oikos?.showToast(doc.status === 'archived' ? t('documents.restoredToast') : t('documents.archivedToast'), 'success'); + await loadDocuments(); + renderDocuments(); + } + if (btn.dataset.action === 'delete') { + if (!confirm(t('documents.deleteConfirm', { name: doc.name }))) return; + await api.delete(`/documents/${doc.id}`); + window.oikos?.showToast(t('documents.deletedToast'), 'success'); + await loadDocuments(); + renderDocuments(); + } +} + +function memberOptions(selected = []) { + const selectedSet = new Set(selected.map(String)); + return state.members.map((member) => ` + + `).join(''); +} + +function openDocumentModal(doc = null) { + const isEdit = !!doc; + openSharedModal({ + title: isEdit ? t('documents.editTitle') : t('documents.newTitle'), + size: 'lg', + content: ` +
+ +
+ + +
+ ${!isEdit ? ` +
+ + +

${t('documents.fileHint')}

+
` : ''} + +
+
${t('documents.allowedMembersLabel')}
+
${memberOptions(doc?.allowed_member_ids || [])}
+
+ + +
+ `, + onSave(panel) { + const form = panel.querySelector('#document-form'); + const visibility = panel.querySelector('#document-visibility'); + const picker = panel.querySelector('#document-member-picker'); + const syncVisibility = () => { picker.hidden = visibility.value !== 'restricted'; }; + visibility.addEventListener('change', syncVisibility); + syncVisibility(); + form.addEventListener('submit', (event) => saveDocument(event, doc)); + }, + }); +} + +async function saveDocument(event, doc) { + event.preventDefault(); + const form = event.target; + const error = form.querySelector('#document-error'); + const submit = form.querySelector('#document-submit'); + error.hidden = true; + submit.disabled = true; + try { + const visibility = form.querySelector('#document-visibility').value; + const payload = { + name: form.querySelector('#document-name').value.trim(), + description: form.querySelector('#document-description').value.trim() || null, + category: form.querySelector('#document-category').value, + visibility, + status: form.querySelector('#document-status').value, + allowed_member_ids: visibility === 'restricted' + ? Array.from(form.querySelectorAll('.document-member-picker input:checked')).map((input) => Number(input.value)) + : [], + }; + if (!doc) { + const file = form.querySelector('#document-file').files?.[0]; + if (!file) throw new Error(t('documents.fileRequired')); + if (file.size > MAX_FILE_SIZE) throw new Error(t('documents.fileTooLarge')); + payload.original_name = file.name; + payload.content_data = await readFileAsDataUrl(file); + if (!payload.name) payload.name = file.name.replace(/\.[^.]+$/, ''); + } + if (!payload.name) throw new Error(t('common.required')); + if (doc) await api.put(`/documents/${doc.id}`, payload); + else await api.post('/documents', payload); + window.oikos?.showToast(doc ? t('documents.savedToast') : t('documents.uploadedToast'), 'success'); + closeModal({ force: true }); + await loadDocuments(); + renderDocuments(); + } catch (err) { + error.textContent = err.message; + error.hidden = false; + } finally { + submit.disabled = false; + } +} + +function readFileAsDataUrl(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(new Error(t('documents.fileReadError'))); + reader.readAsDataURL(file); + }); +} + +function formatFileSize(bytes) { + if (!bytes) return '0 KB'; + if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/public/router.js b/public/router.js index 961d6a3..7a769c0 100644 --- a/public/router.js +++ b/public/router.js @@ -25,6 +25,7 @@ const ROUTES = [ { path: '/recipes', page: '/pages/recipes.js', requiresAuth: true, module: 'recipes' }, { path: '/contacts', page: '/pages/contacts.js', requiresAuth: true, module: 'contacts' }, { path: '/budget', page: '/pages/budget.js', requiresAuth: true, module: 'budget' }, + { path: '/documents', page: '/pages/documents.js', requiresAuth: true, module: 'documents' }, { path: '/settings', page: '/pages/settings.js', requiresAuth: true, module: 'settings' }, ]; @@ -128,7 +129,7 @@ let _pendingLoginRedirect = false; // -------------------------------------------------------- const ROUTE_ORDER = ['/', '/tasks', '/calendar', '/birthdays', '/meals', '/recipes', '/shopping', - '/notes', '/contacts', '/budget', '/settings']; + '/notes', '/contacts', '/budget', '/documents', '/settings']; const PRIMARY_NAV = 4; @@ -181,6 +182,7 @@ function routeTitle(path) { '/notes': t('nav.notes'), '/contacts': t('nav.contacts'), '/budget': t('nav.budget'), + '/documents': t('nav.documents'), '/settings': t('nav.settings'), }; return map[path] || getAppName(); @@ -886,6 +888,7 @@ function navItems() { { path: '/notes', label: t('nav.notes'), icon: 'sticky-note' }, { path: '/contacts', label: t('nav.contacts'), icon: 'book-user' }, { path: '/budget', label: t('nav.budget'), icon: 'wallet' }, + { path: '/documents', label: t('nav.documents'), icon: 'folder-lock' }, { path: '/settings', label: t('nav.settings'), icon: 'settings' }, ]; } diff --git a/public/styles/documents.css b/public/styles/documents.css new file mode 100644 index 0000000..bbcf63d --- /dev/null +++ b/public/styles/documents.css @@ -0,0 +1,279 @@ +/** + * Module: Family Documents + * Purpose: Documents page layout, cards, list rows and upload modal controls. + */ + +.documents-page { + --module-accent: var(--module-documents); + max-width: var(--content-max-width); + margin: 0 auto; +} + +.documents-toolbar { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-top: 3px solid var(--module-accent); + border-bottom: 1px solid var(--color-border); + background-color: var(--color-surface); +} + +.documents-toolbar__title { + font-size: var(--text-lg); + font-weight: var(--font-weight-bold); + flex: 0 0 auto; +} + +.documents-toolbar__search { + position: relative; + flex: 1; + min-width: 180px; +} + +.documents-toolbar__search-icon { + position: absolute; + left: var(--space-3); + top: 50%; + width: 16px; + height: 16px; + transform: translateY(-50%); + color: var(--color-text-tertiary); +} + +.documents-toolbar__search-input { + width: 100%; + min-height: var(--target-base); + padding: 0 var(--space-3) 0 var(--space-9); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface-2); + color: var(--color-text-primary); +} + +.documents-view-toggle { + display: inline-flex; + padding: 2px; + gap: 2px; + border-radius: var(--radius-md); + background: var(--color-surface-2); +} + +.documents-view-toggle__btn { + width: var(--target-base); + height: var(--target-base); + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: calc(var(--radius-md) - 2px); + color: var(--color-text-secondary); +} + +.documents-view-toggle__btn svg { + width: 17px; + height: 17px; +} + +.documents-view-toggle__btn--active { + color: var(--module-accent); + background: var(--color-surface); + box-shadow: var(--shadow-sm); +} + +.documents-filters { + display: flex; + gap: var(--space-3); + padding: var(--space-4); +} + +.documents-filter-select { + max-width: 240px; +} + +.documents-list { + padding: 0 var(--space-4) calc(var(--nav-bottom-height) + var(--space-8)); +} + +.documents-list--grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: var(--space-4); +} + +.documents-list--list { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.document-card, +.document-row { + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + background: var(--color-surface); + box-shadow: var(--shadow-sm); +} + +.document-card { + min-height: 230px; + display: flex; + flex-direction: column; + padding: var(--space-4); +} + +.document-card__icon, +.document-row__icon { + width: 42px; + height: 42px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + color: var(--module-accent); + background: color-mix(in srgb, var(--module-accent) 12%, transparent); +} + +.document-card__icon svg, +.document-row__icon svg { + width: 22px; + height: 22px; +} + +.document-card__body { + flex: 1; + margin-top: var(--space-4); +} + +.document-card__title, +.document-row__title { + margin: 0; + font-size: var(--text-base); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + overflow-wrap: anywhere; +} + +.document-card__description { + min-height: 42px; + margin: var(--space-2) 0 0; + color: var(--color-text-secondary); + font-size: var(--text-sm); + overflow-wrap: anywhere; +} + +.document-card__meta, +.document-row__meta { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-top: var(--space-3); + color: var(--color-text-tertiary); + font-size: var(--text-xs); +} + +.document-card__meta span, +.document-row__meta span { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.document-card__meta svg, +.document-row__meta svg { + width: 13px; + height: 13px; +} + +.document-card__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + margin-top: var(--space-4); + color: var(--color-text-tertiary); + font-size: var(--text-xs); +} + +.document-card__actions, +.document-row__actions { + display: flex; + align-items: center; + gap: var(--space-1); +} + +.document-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); +} + +.document-row__body { + min-width: 0; +} + +.documents-danger { + color: var(--color-danger); +} + +.document-form__hint { + margin-top: var(--space-1); + color: var(--color-text-tertiary); + font-size: var(--text-xs); +} + +.document-member-picker { + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + padding: var(--space-3); + background: var(--color-surface-2); +} + +.document-member-picker__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: var(--space-2); + margin-top: var(--space-2); +} + +.document-member-option { + display: flex; + align-items: center; + gap: var(--space-2); + min-height: var(--target-sm); + color: var(--color-text-secondary); + font-size: var(--text-sm); +} + +@media (max-width: 720px) { + .documents-toolbar { + flex-wrap: wrap; + } + + .documents-toolbar__title { + width: 100%; + } + + .documents-toolbar__search { + order: 4; + flex-basis: 100%; + } + + .documents-filters { + flex-direction: column; + } + + .documents-filter-select { + max-width: none; + } + + .document-row { + grid-template-columns: auto minmax(0, 1fr); + } + + .document-row__actions { + grid-column: 1 / -1; + justify-content: flex-end; + } +} diff --git a/public/styles/tokens.css b/public/styles/tokens.css index 064848f..bd5112a 100644 --- a/public/styles/tokens.css +++ b/public/styles/tokens.css @@ -172,6 +172,8 @@ --module-birthdays: var(--_module-birthdays); /* Rose - Geburtstage */ --_module-budget: #0F766E; --module-budget: var(--_module-budget); /* Teal-700 - Finanzen, Stabilität */ + --_module-documents: #475569; + --module-documents: var(--_module-documents); /* Slate - private family records */ --_module-settings: #6E7781; --module-settings: var(--_module-settings); /* Grau - Konfiguration */ --_module-reminders: #0E7490; diff --git a/public/sw.js b/public/sw.js index 7272c5a..c7e5e5f 100644 --- a/public/sw.js +++ b/public/sw.js @@ -13,10 +13,10 @@ * → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit) */ -const SHELL_CACHE = 'oikos-shell-v66'; -const PAGES_CACHE = 'oikos-pages-v61'; -const LOCALES_CACHE = 'oikos-locales-v10'; -const ASSETS_CACHE = 'oikos-assets-v61'; +const SHELL_CACHE = 'oikos-shell-v67'; +const PAGES_CACHE = 'oikos-pages-v62'; +const LOCALES_CACHE = 'oikos-locales-v11'; +const ASSETS_CACHE = 'oikos-assets-v62'; const BYPASS_CACHE = 'oikos-bypass-flag'; const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, LOCALES_CACHE, ASSETS_CACHE]; @@ -47,6 +47,7 @@ const APP_SHELL = [ '/styles/contacts.css', '/styles/birthdays.css', '/styles/budget.css', + '/styles/documents.css', '/styles/settings.css', '/styles/recipes.css', '/components/oikos-install-prompt.js', @@ -90,6 +91,7 @@ const PAGE_MODULES = [ '/pages/contacts.js', '/pages/birthdays.js', '/pages/budget.js', + '/pages/documents.js', '/pages/settings.js', '/pages/login.js', '/pages/recipes.js', diff --git a/server/db-schema-test.js b/server/db-schema-test.js index 7889e90..880b44b 100644 --- a/server/db-schema-test.js +++ b/server/db-schema-test.js @@ -380,6 +380,45 @@ const MIGRATIONS_SQL = { CREATE INDEX IF NOT EXISTS idx_tasks_assigned ON tasks(assigned_to); CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id); `, + 19: ` + CREATE TABLE IF NOT EXISTS family_documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + category TEXT NOT NULL DEFAULT 'other' + CHECK(category IN ('medical', 'school', 'identity', 'insurance', 'finance', 'home', 'vehicle', 'legal', 'travel', 'pets', 'warranty', 'taxes', 'work', 'other')), + status TEXT NOT NULL DEFAULT 'active' + CHECK(status IN ('active', 'archived')), + visibility TEXT NOT NULL DEFAULT 'family' + CHECK(visibility IN ('family', 'restricted', 'private')), + original_name TEXT NOT NULL, + mime_type TEXT NOT NULL, + file_size INTEGER NOT NULL, + content_data TEXT NOT NULL, + storage_provider TEXT NOT NULL DEFAULT 'local' + CHECK(storage_provider IN ('local', 'external')), + storage_key TEXT, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + CREATE TABLE IF NOT EXISTS family_document_access ( + document_id INTEGER NOT NULL REFERENCES family_documents(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + PRIMARY KEY (document_id, user_id) + ); + + CREATE TRIGGER IF NOT EXISTS trg_family_documents_updated_at + AFTER UPDATE ON family_documents FOR EACH ROW + BEGIN UPDATE family_documents SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END; + + CREATE INDEX IF NOT EXISTS idx_family_documents_status ON family_documents(status); + CREATE INDEX IF NOT EXISTS idx_family_documents_category ON family_documents(category); + CREATE INDEX IF NOT EXISTS idx_family_documents_created_by ON family_documents(created_by); + CREATE INDEX IF NOT EXISTS idx_family_document_access_user ON family_document_access(user_id); + `, }; export { MIGRATIONS_SQL }; diff --git a/server/db.js b/server/db.js index 30d81a8..85892e0 100644 --- a/server/db.js +++ b/server/db.js @@ -810,6 +810,49 @@ const MIGRATIONS = [ CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id); `, }, + { + version: 26, + description: 'Family documents with local storage metadata and visibility ACL', + up: ` + CREATE TABLE IF NOT EXISTS family_documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + category TEXT NOT NULL DEFAULT 'other' + CHECK(category IN ('medical', 'school', 'identity', 'insurance', 'finance', 'home', 'vehicle', 'legal', 'travel', 'pets', 'warranty', 'taxes', 'work', 'other')), + status TEXT NOT NULL DEFAULT 'active' + CHECK(status IN ('active', 'archived')), + visibility TEXT NOT NULL DEFAULT 'family' + CHECK(visibility IN ('family', 'restricted', 'private')), + original_name TEXT NOT NULL, + mime_type TEXT NOT NULL, + file_size INTEGER NOT NULL, + content_data TEXT NOT NULL, + storage_provider TEXT NOT NULL DEFAULT 'local' + CHECK(storage_provider IN ('local', 'external')), + storage_key TEXT, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + CREATE TABLE IF NOT EXISTS family_document_access ( + document_id INTEGER NOT NULL REFERENCES family_documents(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + PRIMARY KEY (document_id, user_id) + ); + + CREATE TRIGGER IF NOT EXISTS trg_family_documents_updated_at + AFTER UPDATE ON family_documents FOR EACH ROW + BEGIN UPDATE family_documents SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END; + + CREATE INDEX IF NOT EXISTS idx_family_documents_status ON family_documents(status); + CREATE INDEX IF NOT EXISTS idx_family_documents_category ON family_documents(category); + CREATE INDEX IF NOT EXISTS idx_family_documents_created_by ON family_documents(created_by); + CREATE INDEX IF NOT EXISTS idx_family_document_access_user ON family_document_access(user_id); + `, + }, ]; /** diff --git a/server/index.js b/server/index.js index 83f3b66..3431711 100644 --- a/server/index.js +++ b/server/index.js @@ -27,6 +27,7 @@ import notesRouter from './routes/notes.js'; import contactsRouter from './routes/contacts.js'; import birthdaysRouter from './routes/birthdays.js'; import budgetRouter from './routes/budget.js'; +import documentsRouter from './routes/documents.js'; import weatherRouter from './routes/weather.js'; import preferencesRouter from './routes/preferences.js'; import remindersRouter from './routes/reminders.js'; @@ -200,6 +201,7 @@ app.use('/api/v1/notes', notesRouter); app.use('/api/v1/contacts', contactsRouter); app.use('/api/v1/birthdays', birthdaysRouter); app.use('/api/v1/budget', budgetRouter); +app.use('/api/v1/documents', documentsRouter); app.use('/api/v1/weather', weatherRouter); app.use('/api/v1/preferences', preferencesRouter); app.use('/api/v1/reminders', remindersRouter); diff --git a/server/routes/documents.js b/server/routes/documents.js new file mode 100644 index 0000000..02a5252 --- /dev/null +++ b/server/routes/documents.js @@ -0,0 +1,262 @@ +/** + * Module: Family Documents + * Purpose: REST API for locally stored family documents with per-member visibility. + * Dependencies: express, server/db.js + */ + +import express from 'express'; +import * as db from '../db.js'; +import { createLogger } from '../logger.js'; +import { str, collectErrors, MAX_TEXT, MAX_TITLE } from '../middleware/validate.js'; + +const log = createLogger('Documents'); +const router = express.Router(); + +const CATEGORIES = ['medical', 'school', 'identity', 'insurance', 'finance', 'home', 'vehicle', 'legal', 'travel', 'pets', 'warranty', 'taxes', 'work', 'other']; +const VISIBILITIES = ['family', 'restricted', 'private']; +const STATUSES = ['active', 'archived']; +const MAX_FILE_BYTES = 5 * 1024 * 1024; +const ALLOWED_MIME = new Set([ + 'application/pdf', + 'image/png', + 'image/jpeg', + 'image/webp', + 'text/plain', + 'text/csv', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', +]); + +function userId(req) { + return req.authUserId || req.session.userId; +} + +function isAdmin(req) { + return req.authRole === 'admin' || req.session?.role === 'admin'; +} + +function canSeeSql(alias = 'd') { + return `( + ${alias}.created_by = @userId + OR ${alias}.visibility = 'family' + OR EXISTS ( + SELECT 1 FROM family_document_access a + WHERE a.document_id = ${alias}.id AND a.user_id = @userId + ) + )`; +} + +function parseMemberIds(value) { + if (!Array.isArray(value)) return []; + return [...new Set(value.map((id) => Number(id)).filter((id) => Number.isInteger(id) && id > 0))]; +} + +function parseDataUrl(dataUrl) { + const raw = String(dataUrl || ''); + const match = raw.match(/^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/); + if (!match) return { error: 'File content must be a valid base64 data URL.' }; + const mime = match[1].toLowerCase(); + if (!ALLOWED_MIME.has(mime)) return { error: 'File type is not allowed.' }; + const base64 = match[2].replace(/\s/g, ''); + const buffer = Buffer.from(base64, 'base64'); + if (!buffer.length) return { error: 'File content is empty.' }; + if (buffer.length > MAX_FILE_BYTES) return { error: 'File may be at most 5 MB.' }; + return { mime, base64, size: buffer.length, buffer }; +} + +function documentSelect() { + return ` + SELECT d.id, d.name, d.description, d.category, d.status, d.visibility, + d.original_name, d.mime_type, d.file_size, d.storage_provider, + d.storage_key, d.created_by, d.created_at, d.updated_at, + u.display_name AS creator_name, u.avatar_color AS creator_color, + GROUP_CONCAT(a.user_id) AS allowed_member_ids + FROM family_documents d + LEFT JOIN users u ON u.id = d.created_by + LEFT JOIN family_document_access a ON a.document_id = d.id + `; +} + +function normalizeDocument(row) { + if (!row) return null; + return { + ...row, + allowed_member_ids: row.allowed_member_ids + ? row.allowed_member_ids.split(',').map((id) => Number(id)).filter(Boolean) + : [], + }; +} + +function getVisibleDocument(id, req, includeContent = false) { + const columns = includeContent ? 'd.*' : 'd.id, d.created_by, d.visibility, d.description'; + return db.get().prepare(` + SELECT ${columns} + FROM family_documents d + WHERE d.id = @id AND ${canSeeSql('d')} + `).get({ id, userId: userId(req) }); +} + +function replaceAccess(documentId, memberIds) { + const database = db.get(); + database.prepare('DELETE FROM family_document_access WHERE document_id = ?').run(documentId); + const insert = database.prepare('INSERT OR IGNORE INTO family_document_access (document_id, user_id) VALUES (?, ?)'); + for (const memberId of memberIds) insert.run(documentId, memberId); +} + +router.get('/meta/options', (_req, res) => { + res.json({ + data: { + categories: CATEGORIES, + visibilities: VISIBILITIES, + statuses: STATUSES, + max_file_size: MAX_FILE_BYTES, + allowed_mime_types: Array.from(ALLOWED_MIME), + storage_providers: ['local'], + }, + }); +}); + +router.get('/', (req, res) => { + try { + const status = STATUSES.includes(req.query.status) ? req.query.status : 'active'; + const category = CATEGORIES.includes(req.query.category) ? req.query.category : null; + const params = { userId: userId(req), status, category }; + const rows = db.get().prepare(` + ${documentSelect()} + WHERE ${canSeeSql('d')} + AND d.status = @status + AND (@category IS NULL OR d.category = @category) + GROUP BY d.id + ORDER BY d.updated_at DESC + `).all(params); + res.json({ data: rows.map(normalizeDocument) }); + } catch (err) { + log.error('GET / error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.post('/', (req, res) => { + try { + const vName = str(req.body.name, 'Name', { max: MAX_TITLE }); + const vDescription = str(req.body.description, 'Description', { max: MAX_TEXT, required: false }); + const vOriginalName = str(req.body.original_name, 'Original filename', { max: MAX_TITLE }); + const errors = collectErrors([vName, vDescription, vOriginalName]); + if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); + + const category = CATEGORIES.includes(req.body.category) ? req.body.category : 'other'; + const visibility = VISIBILITIES.includes(req.body.visibility) ? req.body.visibility : 'family'; + const parsed = parseDataUrl(req.body.content_data); + if (parsed.error) return res.status(400).json({ error: parsed.error, code: 400 }); + + const allowedIds = visibility === 'restricted' ? parseMemberIds(req.body.allowed_member_ids) : []; + const database = db.get(); + const result = database.prepare(` + INSERT INTO family_documents + (name, description, category, visibility, original_name, mime_type, file_size, content_data, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(vName.value, vDescription.value, category, visibility, vOriginalName.value, parsed.mime, parsed.size, parsed.base64, userId(req)); + if (visibility === 'restricted') replaceAccess(result.lastInsertRowid, allowedIds); + + const row = database.prepare(` + ${documentSelect()} + WHERE d.id = ? + GROUP BY d.id + `).get(result.lastInsertRowid); + res.status(201).json({ data: normalizeDocument(row) }); + } catch (err) { + log.error('POST / error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.put('/:id', (req, res) => { + try { + const id = Number(req.params.id); + const existing = getVisibleDocument(id, req); + if (!existing) return res.status(404).json({ error: 'Document not found.', code: 404 }); + if (existing.created_by !== userId(req) && !isAdmin(req)) return res.status(403).json({ error: 'Not authorized.', code: 403 }); + + const vName = req.body.name !== undefined ? str(req.body.name, 'Name', { max: MAX_TITLE }) : { value: null }; + const vDescription = req.body.description !== undefined ? str(req.body.description, 'Description', { max: MAX_TEXT, required: false }) : { value: null }; + const errors = collectErrors([vName, vDescription]); + if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); + + const category = req.body.category !== undefined && CATEGORIES.includes(req.body.category) ? req.body.category : null; + const visibility = req.body.visibility !== undefined && VISIBILITIES.includes(req.body.visibility) ? req.body.visibility : null; + const status = req.body.status !== undefined && STATUSES.includes(req.body.status) ? req.body.status : null; + db.get().prepare(` + UPDATE family_documents + SET name = COALESCE(?, name), + description = ?, + category = COALESCE(?, category), + visibility = COALESCE(?, visibility), + status = COALESCE(?, status) + WHERE id = ? + `).run( + req.body.name !== undefined ? vName.value : null, + req.body.description !== undefined ? vDescription.value : existing.description, + category, + visibility, + status, + id + ); + if ((visibility || existing.visibility) === 'restricted') replaceAccess(id, parseMemberIds(req.body.allowed_member_ids)); + else replaceAccess(id, []); + + const row = db.get().prepare(`${documentSelect()} WHERE d.id = ? GROUP BY d.id`).get(id); + res.json({ data: normalizeDocument(row) }); + } catch (err) { + log.error('PUT /:id error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.patch('/:id/archive', (req, res) => { + try { + const id = Number(req.params.id); + const existing = getVisibleDocument(id, req); + if (!existing) return res.status(404).json({ error: 'Document not found.', code: 404 }); + if (existing.created_by !== userId(req) && !isAdmin(req)) return res.status(403).json({ error: 'Not authorized.', code: 403 }); + const status = req.body.archived === false ? 'active' : 'archived'; + db.get().prepare('UPDATE family_documents SET status = ? WHERE id = ?').run(status, id); + res.json({ data: { id, status } }); + } catch (err) { + log.error('PATCH /:id/archive error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.get('/:id/download', (req, res) => { + try { + const id = Number(req.params.id); + const doc = getVisibleDocument(id, req, true); + if (!doc) return res.status(404).json({ error: 'Document not found.', code: 404 }); + const filename = encodeURIComponent(doc.original_name.replace(/[/\\]/g, '_')); + res.setHeader('Content-Type', doc.mime_type); + res.setHeader('Content-Length', String(doc.file_size)); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.end(Buffer.from(doc.content_data, 'base64')); + } catch (err) { + log.error('GET /:id/download error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.delete('/:id', (req, res) => { + try { + const id = Number(req.params.id); + const existing = getVisibleDocument(id, req); + if (!existing) return res.status(404).json({ error: 'Document not found.', code: 404 }); + if (existing.created_by !== userId(req) && !isAdmin(req)) return res.status(403).json({ error: 'Not authorized.', code: 403 }); + db.get().prepare('DELETE FROM family_documents WHERE id = ?').run(id); + res.status(204).end(); + } catch (err) { + log.error('DELETE /:id error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +export default router;