feat(documents): add family document management

This commit is contained in:
Rafael Foster
2026-04-29 06:14:29 -03:00
parent 6eafe80395
commit 72fca92066
24 changed files with 1927 additions and 33 deletions
+61 -2
View File
@@ -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": "أخرى"
}
}
}
}
+61 -2
View File
@@ -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"
}
}
}
}
+61 -2
View File
@@ -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": "Άλλο"
}
}
}
}
+60 -1
View File
@@ -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"
}
}
}
+61 -2
View File
@@ -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"
}
}
}
}
+61 -2
View File
@@ -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"
}
}
}
}
+61 -2
View File
@@ -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": "अन्य"
}
}
}
}
+61 -2
View File
@@ -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"
}
}
}
}
+61 -2
View File
@@ -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": "その他"
}
}
}
}
+60 -1
View File
@@ -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"
}
}
}
+61 -2
View File
@@ -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": "Другое"
}
}
}
}
+61 -2
View File
@@ -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"
}
}
}
}
+61 -2
View File
@@ -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"
}
}
}
}
+61 -2
View File
@@ -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": "Інше"
}
}
}
}
+61 -2
View File
@@ -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": "其他"
}
}
}
}
+377
View File
@@ -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 = `
<div class="documents-page">
<div class="documents-toolbar">
<h1 class="documents-toolbar__title">${t('documents.title')}</h1>
<div class="documents-toolbar__search">
<i data-lucide="search" class="documents-toolbar__search-icon" aria-hidden="true"></i>
<input class="documents-toolbar__search-input" id="documents-search" type="search" placeholder="${t('documents.searchPlaceholder')}" autocomplete="off">
</div>
<div class="documents-view-toggle" role="group" aria-label="${t('documents.viewToggle')}">
<button class="documents-view-toggle__btn ${state.view === 'grid' ? 'documents-view-toggle__btn--active' : ''}" data-view="grid" aria-label="${t('documents.gridView')}">
<i data-lucide="layout-grid" aria-hidden="true"></i>
</button>
<button class="documents-view-toggle__btn ${state.view === 'list' ? 'documents-view-toggle__btn--active' : ''}" data-view="list" aria-label="${t('documents.listView')}">
<i data-lucide="list" aria-hidden="true"></i>
</button>
</div>
<button class="btn btn--primary" id="documents-add-btn">
<i data-lucide="upload" class="icon-base" aria-hidden="true"></i>
${t('documents.addButton')}
</button>
</div>
<div class="documents-filters">
<select class="input documents-filter-select" id="documents-status">
<option value="active">${t('documents.statusActive')}</option>
<option value="archived">${t('documents.statusArchived')}</option>
</select>
<select class="input documents-filter-select" id="documents-category">
<option value="">${t('documents.allCategories')}</option>
${CATEGORIES.map((category) => `<option value="${category}">${categoryLabels()[category]}</option>`).join('')}
</select>
</div>
<div id="documents-list" class="documents-list documents-list--${state.view}"></div>
<button class="page-fab" id="fab-new-document" aria-label="${t('documents.addButton')}">
<i data-lucide="upload" class="icon-2xl" aria-hidden="true"></i>
</button>
</div>
`;
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 = `
<div class="empty-state">
<i data-lucide="folder-open" class="empty-state__icon" aria-hidden="true"></i>
<div class="empty-state__title">${t('documents.emptyTitle')}</div>
<div class="empty-state__description">${t('documents.emptyDescription')}</div>
</div>
`;
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 `
<span><i data-lucide="${CATEGORY_ICONS[doc.category] || 'folder'}" aria-hidden="true"></i>${labels[doc.category] || doc.category}</span>
<span><i data-lucide="${doc.visibility === 'family' ? 'users' : doc.visibility === 'private' ? 'lock' : 'user-check'}" aria-hidden="true"></i>${t(`documents.visibility.${doc.visibility}`)}</span>
<span>${formatFileSize(doc.file_size)}</span>
`;
}
function renderActions(doc) {
return `
<a class="btn btn--ghost btn--icon btn--icon-sm" href="/api/v1/documents/${doc.id}/download" download title="${t('documents.downloadAction')}" aria-label="${t('documents.downloadAction')}">
<i data-lucide="download" class="icon-base" aria-hidden="true"></i>
</a>
<button class="btn btn--ghost btn--icon btn--icon-sm" data-action="edit" data-id="${doc.id}" title="${t('documents.editAction')}" aria-label="${t('documents.editAction')}">
<i data-lucide="settings" class="icon-base" aria-hidden="true"></i>
</button>
<button class="btn btn--ghost btn--icon btn--icon-sm" data-action="archive" data-id="${doc.id}" data-archived="${doc.status === 'archived'}" title="${doc.status === 'archived' ? t('documents.restoreAction') : t('documents.archiveAction')}" aria-label="${doc.status === 'archived' ? t('documents.restoreAction') : t('documents.archiveAction')}">
<i data-lucide="${doc.status === 'archived' ? 'archive-restore' : 'archive'}" class="icon-base" aria-hidden="true"></i>
</button>
<button class="btn btn--ghost btn--icon btn--icon-sm documents-danger" data-action="delete" data-id="${doc.id}" title="${t('common.delete')}" aria-label="${t('common.delete')}">
<i data-lucide="trash-2" class="icon-base" aria-hidden="true"></i>
</button>
`;
}
function renderGridCard(doc) {
return `
<article class="document-card" data-id="${doc.id}">
<div class="document-card__icon"><i data-lucide="${CATEGORY_ICONS[doc.category] || 'file'}" aria-hidden="true"></i></div>
<div class="document-card__body">
<h2 class="document-card__title">${esc(doc.name)}</h2>
<p class="document-card__description">${esc(doc.description || doc.original_name)}</p>
<div class="document-card__meta">${renderMeta(doc)}</div>
</div>
<div class="document-card__footer">
<span>${formatDate(doc.updated_at)}</span>
<div class="document-card__actions">${renderActions(doc)}</div>
</div>
</article>
`;
}
function renderListItem(doc) {
return `
<article class="document-row" data-id="${doc.id}">
<div class="document-row__icon"><i data-lucide="${CATEGORY_ICONS[doc.category] || 'file'}" aria-hidden="true"></i></div>
<div class="document-row__body">
<h2 class="document-row__title">${esc(doc.name)}</h2>
<div class="document-row__meta">${renderMeta(doc)}</div>
</div>
<div class="document-row__actions">${renderActions(doc)}</div>
</article>
`;
}
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) => `
<label class="document-member-option">
<input type="checkbox" value="${member.id}" ${selectedSet.has(String(member.id)) ? 'checked' : ''}>
<span>${esc(member.display_name)}</span>
</label>
`).join('');
}
function openDocumentModal(doc = null) {
const isEdit = !!doc;
openSharedModal({
title: isEdit ? t('documents.editTitle') : t('documents.newTitle'),
size: 'lg',
content: `
<form id="document-form" class="document-form">
<div class="modal-grid modal-grid--2">
<div class="form-group">
<label class="label" for="document-name">${t('documents.nameLabel')}</label>
<input class="input" id="document-name" name="name" required maxlength="200" value="${esc(doc?.name || '')}">
</div>
<div class="form-group">
<label class="label" for="document-category">${t('documents.categoryLabel')}</label>
<select class="input" id="document-category">
${CATEGORIES.map((category) => `<option value="${category}" ${doc?.category === category ? 'selected' : ''}>${categoryLabels()[category]}</option>`).join('')}
</select>
</div>
</div>
<div class="form-group">
<label class="label" for="document-description">${t('documents.descriptionLabel')}</label>
<textarea class="input" id="document-description" rows="3" maxlength="5000">${esc(doc?.description || '')}</textarea>
</div>
${!isEdit ? `
<div class="form-group">
<label class="label" for="document-file">${t('documents.fileLabel')}</label>
<input class="input" id="document-file" type="file" required>
<p class="document-form__hint">${t('documents.fileHint')}</p>
</div>` : ''}
<div class="modal-grid modal-grid--2">
<div class="form-group">
<label class="label" for="document-visibility">${t('documents.visibilityLabel')}</label>
<select class="input" id="document-visibility">
<option value="family" ${doc?.visibility === 'family' ? 'selected' : ''}>${t('documents.visibility.family')}</option>
<option value="restricted" ${doc?.visibility === 'restricted' ? 'selected' : ''}>${t('documents.visibility.restricted')}</option>
<option value="private" ${doc?.visibility === 'private' ? 'selected' : ''}>${t('documents.visibility.private')}</option>
</select>
</div>
<div class="form-group">
<label class="label" for="document-status">${t('documents.statusLabel')}</label>
<select class="input" id="document-status">
<option value="active" ${doc?.status !== 'archived' ? 'selected' : ''}>${t('documents.statusActive')}</option>
<option value="archived" ${doc?.status === 'archived' ? 'selected' : ''}>${t('documents.statusArchived')}</option>
</select>
</div>
</div>
<div class="document-member-picker" id="document-member-picker">
<div class="label">${t('documents.allowedMembersLabel')}</div>
<div class="document-member-picker__grid">${memberOptions(doc?.allowed_member_ids || [])}</div>
</div>
<div id="document-error" class="login-error" hidden></div>
<div class="modal-panel__footer" style="padding:0;border:none;margin-top:var(--space-5)">
<button type="submit" class="btn btn--primary" id="document-submit">${isEdit ? t('common.save') : t('documents.uploadAction')}</button>
</div>
</form>
`,
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`;
}
+4 -1
View File
@@ -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' },
];
}
+279
View File
@@ -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;
}
}
+2
View File
@@ -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;
+6 -4
View File
@@ -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',
+39
View File
@@ -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 };
+43
View File
@@ -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);
`,
},
];
/**
+2
View File
@@ -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);
+262
View File
@@ -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;