diff --git a/public/components/modal.js b/public/components/modal.js
index 4dcb51f..c124191 100644
--- a/public/components/modal.js
+++ b/public/components/modal.js
@@ -105,7 +105,7 @@ function trapFocus(container) {
// --------------------------------------------------------
function serializeForm(container) {
- const inputs = container.querySelectorAll('input, select, textarea');
+ const inputs = container.querySelectorAll('input:not([type="file"]), select, textarea');
return Array.from(inputs).map((el) => `${el.name || el.id}=${el.value}`).join('&');
}
@@ -327,17 +327,33 @@ export async function closeModal({ force = false } = {}) {
if (!force) {
const panel = activeOverlay.querySelector('.modal-panel');
if (panel && isFormDirty(panel)) {
+ const dirtyOverlay = activeOverlay;
+ const dirtySnapshot = _initialFormSnapshot;
let confirmed;
try {
+ activeOverlay = null;
+ _isClosing = false;
confirmed = await confirmModal(t('modal.unsavedChanges'), {
danger: false,
confirmLabel: t('modal.discardChanges'),
});
} catch (err) {
+ activeOverlay = dirtyOverlay;
+ _initialFormSnapshot = dirtySnapshot;
_isClosing = false;
throw err;
}
- if (!confirmed) { _isClosing = false; return; }
+ activeOverlay = dirtyOverlay;
+ _initialFormSnapshot = dirtySnapshot;
+ if (!confirmed) {
+ document.body.style.overflow = 'hidden';
+ if (window.oikos?.setThemeColor) {
+ window.oikos.setThemeColor(OVERLAY_THEME_COLOR, OVERLAY_THEME_COLOR);
+ }
+ _isClosing = false;
+ return;
+ }
+ _isClosing = true;
}
}
diff --git a/public/locales/ar.json b/public/locales/ar.json
index 5a2fafb..30cbf0c 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,66 @@
},
"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": "أخرى"
+ },
+ "dropzoneTitle": "أفلت الملف هنا أو انقر للاختيار",
+ "dropzoneHint": "اسحب ملفًا إلى هذه المنطقة أو استخدم محدد الملفات.",
+ "selectedFileLabel": "المحدد: {{name}}"
}
-}
\ No newline at end of file
+}
diff --git a/public/locales/de.json b/public/locales/de.json
index 7a6e61e..b60674f 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,66 @@
"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"
+ },
+ "dropzoneTitle": "Datei hier ablegen oder klicken",
+ "dropzoneHint": "Ziehe eine Datei in diesen Bereich oder nutze die Dateiauswahl.",
+ "selectedFileLabel": "Ausgewählt: {{name}}"
}
-}
\ No newline at end of file
+}
diff --git a/public/locales/el.json b/public/locales/el.json
index 612be4a..27e250f 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,66 @@
},
"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": "Άλλο"
+ },
+ "dropzoneTitle": "Αφήστε το αρχείο εδώ ή κάντε κλικ για επιλογή",
+ "dropzoneHint": "Σύρετε ένα αρχείο σε αυτήν την περιοχή ή χρησιμοποιήστε τον επιλογέα αρχείων.",
+ "selectedFileLabel": "Επιλέχθηκε: {{name}}"
}
-}
\ No newline at end of file
+}
diff --git a/public/locales/en.json b/public/locales/en.json
index bd783ec..77a4869 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",
@@ -131,6 +132,7 @@
"statusOpen": "Open",
"statusInProgress": "In Progress",
"statusDone": "Done",
+ "statusArchived": "Archived",
"categoryHousehold": "Household",
"categorySchool": "School",
"categoryShopping": "Shopping",
@@ -152,6 +154,7 @@
"markDone": "Mark {{title}} as done",
"markOpen": "Mark {{title}} as open",
"editButton": "Edit task",
+ "archiveButton": "Archive task",
"swipeOpen": "Reopen",
"swipeDone": "Done",
"swipeEdit": "Edit",
@@ -162,11 +165,13 @@
"savedToast": "Task saved.",
"createdToast": "Task created.",
"deletedToast": "Task deleted.",
+ "archivedToast": "Task archived.",
"loadError": "Task could not be loaded.",
"subtaskPrompt": "Subtask:",
"kanbanOpen": "Open",
"kanbanInProgress": "In Progress",
"kanbanDone": "Done",
+ "kanbanArchived": "Archived",
"kanbanMoveToInProgress": "Set to in progress",
"kanbanMoveToDone": "Mark as done",
"kanbanMoveToOpen": "Reopen",
@@ -179,7 +184,8 @@
"filterGroupPriority": "Priority",
"filterGroupStatus": "Status",
"swipedDoneToast": "Marked as done.",
- "swipedOpenToast": "Marked as open."
+ "swipedOpenToast": "Marked as open.",
+ "reminderNeedsDueDate": "Set a due date to enable task reminders."
},
"shopping": {
"title": "Shopping",
@@ -916,5 +922,66 @@
"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"
+ },
+ "dropzoneTitle": "Drop file here or click to choose",
+ "dropzoneHint": "Drag a file into this area, or use the file picker.",
+ "selectedFileLabel": "Selected: {{name}}"
}
-}
\ No newline at end of file
+}
diff --git a/public/locales/es.json b/public/locales/es.json
index 2be26bd..63f2403 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,66 @@
},
"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"
+ },
+ "dropzoneTitle": "Suelta el archivo aquí o haz clic para elegir",
+ "dropzoneHint": "Arrastra un archivo a esta área o usa el selector de archivos.",
+ "selectedFileLabel": "Seleccionado: {{name}}"
}
-}
\ No newline at end of file
+}
diff --git a/public/locales/fr.json b/public/locales/fr.json
index bd2e99d..5f2d9bc 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,66 @@
},
"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"
+ },
+ "dropzoneTitle": "Déposez le fichier ici ou cliquez pour choisir",
+ "dropzoneHint": "Glissez un fichier dans cette zone ou utilisez le sélecteur.",
+ "selectedFileLabel": "Sélectionné : {{name}}"
}
-}
\ No newline at end of file
+}
diff --git a/public/locales/hi.json b/public/locales/hi.json
index 7f7bc16..0140a78 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,66 @@
},
"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": "अन्य"
+ },
+ "dropzoneTitle": "फ़ाइल यहाँ छोड़ें या चुनने के लिए क्लिक करें",
+ "dropzoneHint": "फ़ाइल को इस क्षेत्र में खींचें या फ़ाइल पिकर का उपयोग करें।",
+ "selectedFileLabel": "चयनित: {{name}}"
}
-}
\ No newline at end of file
+}
diff --git a/public/locales/it.json b/public/locales/it.json
index 2dff300..a186987 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,66 @@
},
"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"
+ },
+ "dropzoneTitle": "Rilascia il file qui o fai clic per scegliere",
+ "dropzoneHint": "Trascina un file in quest’area oppure usa il selettore.",
+ "selectedFileLabel": "Selezionato: {{name}}"
}
-}
\ No newline at end of file
+}
diff --git a/public/locales/ja.json b/public/locales/ja.json
index c983173..af14261 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,66 @@
},
"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": "その他"
+ },
+ "dropzoneTitle": "ここにファイルをドロップ、またはクリックして選択",
+ "dropzoneHint": "この領域にファイルをドラッグするか、ファイル選択を使用します。",
+ "selectedFileLabel": "選択済み: {{name}}"
}
-}
\ No newline at end of file
+}
diff --git a/public/locales/pt.json b/public/locales/pt.json
index ae55e25..cc192bc 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",
@@ -131,6 +132,7 @@
"statusOpen": "Aberto",
"statusInProgress": "Em andamento",
"statusDone": "Concluído",
+ "statusArchived": "Arquivado",
"categoryHousehold": "Casa",
"categorySchool": "Escola",
"categoryShopping": "Compras",
@@ -152,6 +154,7 @@
"markDone": "Marcar {{title}} como concluído",
"markOpen": "Marcar {{title}} como pendente",
"editButton": "Editar tarefa",
+ "archiveButton": "Arquivar tarefa",
"swipeOpen": "Abrir",
"swipeDone": "Concluído",
"swipeEdit": "Editar",
@@ -162,11 +165,13 @@
"savedToast": "Tarefa salva.",
"createdToast": "Tarefa criada.",
"deletedToast": "Tarefa excluída.",
+ "archivedToast": "Tarefa arquivada.",
"loadError": "Falha ao carregar a tarefa.",
"subtaskPrompt": "Subtarefa:",
"kanbanOpen": "Aberto",
"kanbanInProgress": "Em andamento",
"kanbanDone": "Concluído",
+ "kanbanArchived": "Arquivado",
"kanbanMoveToInProgress": "Mover para em andamento",
"kanbanMoveToDone": "Marcar como concluído",
"kanbanMoveToOpen": "Reabrir",
@@ -179,7 +184,8 @@
"filterGroupPriority": "Prioridade",
"filterGroupStatus": "Estado",
"swipedDoneToast": "Marcado como concluído.",
- "swipedOpenToast": "Marcado como aberto."
+ "swipedOpenToast": "Marcado como aberto.",
+ "reminderNeedsDueDate": "Defina uma data de vencimento para habilitar lembretes da tarefa."
},
"shopping": {
"title": "Compras",
@@ -898,5 +904,66 @@
},
"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"
+ },
+ "dropzoneTitle": "Solte o arquivo aqui ou clique para escolher",
+ "dropzoneHint": "Arraste um arquivo para esta area, ou use o seletor de arquivos.",
+ "selectedFileLabel": "Selecionado: {{name}}"
}
-}
\ No newline at end of file
+}
diff --git a/public/locales/ru.json b/public/locales/ru.json
index 2e5881a..cbde67e 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,66 @@
},
"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": "Другое"
+ },
+ "dropzoneTitle": "Перетащите файл сюда или нажмите для выбора",
+ "dropzoneHint": "Перетащите файл в эту область или используйте выбор файла.",
+ "selectedFileLabel": "Выбрано: {{name}}"
}
-}
\ No newline at end of file
+}
diff --git a/public/locales/sv.json b/public/locales/sv.json
index 342d0f0..22ccc90 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,66 @@
},
"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"
+ },
+ "dropzoneTitle": "Släpp filen här eller klicka för att välja",
+ "dropzoneHint": "Dra en fil till området eller använd filväljaren.",
+ "selectedFileLabel": "Vald: {{name}}"
}
-}
\ No newline at end of file
+}
diff --git a/public/locales/tr.json b/public/locales/tr.json
index 6088b79..9129103 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,66 @@
},
"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"
+ },
+ "dropzoneTitle": "Dosyayı buraya bırakın veya seçmek için tıklayın",
+ "dropzoneHint": "Bir dosyayı bu alana sürükleyin veya dosya seçiciyi kullanın.",
+ "selectedFileLabel": "Seçildi: {{name}}"
}
-}
\ No newline at end of file
+}
diff --git a/public/locales/uk.json b/public/locales/uk.json
index 5333a59..e0c66de 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,66 @@
"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": "Інше"
+ },
+ "dropzoneTitle": "Перетягніть файл сюди або натисніть для вибору",
+ "dropzoneHint": "Перетягніть файл у цю область або скористайтеся вибором файлу.",
+ "selectedFileLabel": "Вибрано: {{name}}"
}
-}
\ No newline at end of file
+}
diff --git a/public/locales/zh.json b/public/locales/zh.json
index cf396f7..730b037 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,66 @@
},
"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": "其他"
+ },
+ "dropzoneTitle": "将文件拖到此处或点击选择",
+ "dropzoneHint": "将文件拖入此区域,或使用文件选择器。",
+ "selectedFileLabel": "已选择:{{name}}"
}
-}
\ No newline at end of file
+}
diff --git a/public/pages/documents.js b/public/pages/documents.js
new file mode 100644
index 0000000..57cf30d
--- /dev/null
+++ b/public/pages/documents.js
@@ -0,0 +1,421 @@
+/**
+ * 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 = `
+
+
+
+
+
+
+
+
+
+ `;
+
+ 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: `
+
+ `,
+ 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();
+ bindDropzone(panel);
+ form.addEventListener('submit', (event) => saveDocument(event, doc));
+ },
+ });
+}
+
+function bindDropzone(panel) {
+ const dropzone = panel.querySelector('#document-dropzone');
+ const input = panel.querySelector('#document-file');
+ const selected = panel.querySelector('#document-selected-file');
+ if (!dropzone || !input || !selected) return;
+
+ const syncSelectedFile = () => {
+ const file = input.files?.[0];
+ selected.hidden = !file;
+ selected.textContent = file ? t('documents.selectedFileLabel', { name: file.name }) : '';
+ };
+
+ input.addEventListener('change', syncSelectedFile);
+ ['dragenter', 'dragover'].forEach((eventName) => {
+ dropzone.addEventListener(eventName, (event) => {
+ event.preventDefault();
+ dropzone.classList.add('document-dropzone--active');
+ });
+ });
+ ['dragleave', 'drop'].forEach((eventName) => {
+ dropzone.addEventListener(eventName, (event) => {
+ event.preventDefault();
+ dropzone.classList.remove('document-dropzone--active');
+ });
+ });
+ dropzone.addEventListener('drop', (event) => {
+ const file = event.dataTransfer?.files?.[0];
+ if (!file) return;
+ const transfer = new DataTransfer();
+ transfer.items.add(file);
+ input.files = transfer.files;
+ syncSelectedFile();
+ });
+}
+
+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/pages/tasks.js b/public/pages/tasks.js
index 2cd1932..2d513b1 100644
--- a/public/pages/tasks.js
+++ b/public/pages/tasks.js
@@ -30,6 +30,7 @@ const STATUSES = () => [
{ value: 'open', label: t('tasks.statusOpen') },
{ value: 'in_progress', label: t('tasks.statusInProgress') },
{ value: 'done', label: t('tasks.statusDone') },
+ { value: 'archived', label: t('tasks.statusArchived') },
];
const CATEGORIES = [
@@ -208,6 +209,11 @@ function renderTaskCard(task, opts = {}) {
aria-label="${t('tasks.editButton')}">
+ ${task.status !== 'archived' ? `
+ ` : ''}
${progress !== null ? `
@@ -376,7 +382,7 @@ function renderModalContent({ task = null, users = [], reminder = null } = {}) {
${renderRRuleFields('task', task?.recurrence_rule)}
- ${renderReminderSection(reminder)}
+ ${renderReminderSection(task, reminder)}
@@ -446,10 +452,36 @@ async function loadReminderForTask(taskId) {
}
}
-function renderReminderSection(reminder = null) {
- const hasReminder = !!reminder;
- const remindDate = hasReminder ? reminder.remind_at.slice(0, 10) : '';
- const remindTime = hasReminder ? reminder.remind_at.slice(11, 16) : '';
+function parseOffsetMsFromReminder(task, reminder) {
+ if (!task?.due_date || !reminder?.remind_at) return null;
+ const due = task.due_time ? new Date(`${task.due_date}T${task.due_time}`) : new Date(`${task.due_date}T23:59:59`);
+ const remind = new Date(reminder.remind_at);
+ if (Number.isNaN(due.getTime()) || Number.isNaN(remind.getTime())) return null;
+ return due.getTime() - remind.getTime();
+}
+
+function resolveReminderPreset(task, reminder) {
+ const offset = parseOffsetMsFromReminder(task, reminder);
+ if (offset === null) return { preset: 'offset_15m', amount: '15', unit: 'minutes' };
+ const map = new Map([
+ [0, 'offset_at_time'],
+ [15 * 60 * 1000, 'offset_15m'],
+ [60 * 60 * 1000, 'offset_1h'],
+ [24 * 60 * 60 * 1000, 'offset_1d'],
+ [2 * 24 * 60 * 60 * 1000, 'offset_2d'],
+ [7 * 24 * 60 * 60 * 1000, 'offset_1w'],
+ [14 * 24 * 60 * 60 * 1000, 'offset_2w'],
+ ]);
+ if (map.has(offset)) return { preset: map.get(offset), amount: '1', unit: 'days' };
+ const minutes = Math.round(offset / 60000);
+ if (minutes > 0) return { preset: 'offset_custom', amount: String(minutes), unit: 'minutes' };
+ return { preset: 'offset_at_time', amount: '1', unit: 'days' };
+}
+
+function renderReminderSection(task = null, reminder = null) {
+ const hasReminder = !!reminder;
+ const resolved = resolveReminderPreset(task, reminder);
+ const showCustom = hasReminder && resolved.preset === 'offset_custom';
return `
@@ -462,12 +494,33 @@ function renderReminderSection(reminder = null) {
`;
@@ -484,6 +537,7 @@ function openTaskModal({ task = null, users = [], reminder = null } = {}, contai
content: renderModalContent({ task, users, reminder }),
size: 'lg',
onSave(panel) {
+ panel.querySelector('.modal-panel__body')?.classList.add('modal-panel__body--tasks-fit');
// RRULE-Events binden
bindRRuleEvents(document, 'task');
@@ -493,9 +547,15 @@ function openTaskModal({ task = null, users = [], reminder = null } = {}, contai
// Reminder-Toggle: Felder ein-/ausblenden
const toggle = panel.querySelector('#reminder-toggle');
const fields = panel.querySelector('#reminder-fields');
+ const offset = panel.querySelector('#reminder-offset');
+ const customFields = panel.querySelector('#reminder-custom-fields');
toggle?.addEventListener('change', () => {
fields.style.display = toggle.checked ? '' : 'none';
});
+ offset?.addEventListener('change', () => {
+ if (!customFields) return;
+ customFields.style.display = offset.value === 'offset_custom' ? '' : 'none';
+ });
panel.querySelectorAll('.js-date-input').forEach((input) => {
input.addEventListener('blur', () => {
const parsed = parseDateInput(input.value);
@@ -537,9 +597,7 @@ async function handleFormSubmit(e, container) {
const dueDate = parseDateInput(dueDateRaw);
const rrule = getRRuleValues(document, 'task');
const reminderToggle = form.querySelector('#reminder-toggle');
- const reminderDateRaw = form.querySelector('#reminder-date')?.value || '';
- const reminderDate = parseDateInput(reminderDateRaw);
- if (!isDateInputValid(dueDateRaw) || !rrule.valid_until || (reminderToggle?.checked && !isDateInputValid(reminderDateRaw))) {
+ if (!isDateInputValid(dueDateRaw) || !rrule.valid_until) {
errorEl.textContent = t('calendar.invalidDate');
errorEl.hidden = false;
submitBtn.disabled = false;
@@ -572,10 +630,27 @@ async function handleFormSubmit(e, container) {
// Erinnerung speichern oder löschen
if (savedTaskId) {
- const reminderTime = form.querySelector('#reminder-time')?.value || '08:00';
-
- if (reminderToggle?.checked && reminderDate) {
- const remindAt = `${reminderDate}T${reminderTime}`;
+ if (reminderToggle?.checked) {
+ if (!dueDate) throw new Error(t('tasks.reminderNeedsDueDate'));
+ const dueDateTime = body.due_time ? new Date(`${dueDate}T${body.due_time}`) : new Date(`${dueDate}T23:59:59`);
+ const offsetPreset = form.querySelector('#reminder-offset')?.value || 'offset_none';
+ if (offsetPreset === 'offset_none') throw new Error(t('tasks.reminderNeedsDueDate'));
+ let offsetMs = 0;
+ if (offsetPreset === 'offset_15m') offsetMs = 15 * 60 * 1000;
+ else if (offsetPreset === 'offset_1h') offsetMs = 60 * 60 * 1000;
+ else if (offsetPreset === 'offset_1d') offsetMs = 24 * 60 * 60 * 1000;
+ else if (offsetPreset === 'offset_2d') offsetMs = 2 * 24 * 60 * 60 * 1000;
+ else if (offsetPreset === 'offset_1w') offsetMs = 7 * 24 * 60 * 60 * 1000;
+ else if (offsetPreset === 'offset_2w') offsetMs = 14 * 24 * 60 * 60 * 1000;
+ else if (offsetPreset === 'offset_custom') {
+ const customAmount = Number(form.querySelector('#reminder-custom-amount')?.value || 0);
+ const customUnit = form.querySelector('#reminder-custom-unit')?.value || 'days';
+ if (!Number.isFinite(customAmount) || customAmount <= 0) throw new Error(t('common.invalidInput'));
+ const unitFactor = customUnit === 'minutes' ? 60000 : customUnit === 'hours' ? 3600000 : customUnit === 'days' ? 86400000 : 604800000;
+ offsetMs = customAmount * unitFactor;
+ }
+ const remindAtDate = new Date(dueDateTime.getTime() - offsetMs);
+ const remindAt = remindAtDate.toISOString().slice(0, 19);
await api.post('/reminders', { entity_type: 'task', entity_id: savedTaskId, remind_at: remindAt });
refreshReminders();
} else if (!reminderToggle?.checked) {
@@ -643,6 +718,7 @@ const KANBAN_COLS = () => [
{ status: 'open', label: t('tasks.kanbanOpen'), colorVar: '--color-text-secondary' },
{ status: 'in_progress', label: t('tasks.kanbanInProgress'), colorVar: '--color-warning' },
{ status: 'done', label: t('tasks.kanbanDone'), colorVar: '--color-success' },
+ { status: 'archived', label: t('tasks.kanbanArchived'), colorVar: '--color-text-tertiary' },
];
function kanbanNextStatus(status) {
@@ -1440,6 +1516,16 @@ function wireTaskList(container) {
}
}
+ if (action === 'archive-task') {
+ try {
+ await api.patch(`/tasks/${id}/status`, { status: 'archived' });
+ window.oikos.showToast(t('tasks.archivedToast'), 'success');
+ await loadTasks(container);
+ } catch (err) {
+ window.oikos.showToast(err.message, 'danger');
+ }
+ }
+
if (action === 'add-subtask') {
await handleAddSubtask(target.dataset.parent, container);
}
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/rrule-ui.js b/public/rrule-ui.js
index 67deb64..f169b40 100644
--- a/public/rrule-ui.js
+++ b/public/rrule-ui.js
@@ -106,6 +106,11 @@ export function renderRRuleFields(prefix, existingRule) {
${unitLabel(parsed.freq, parsed.interval)}
+
+
+
+
@@ -113,11 +118,6 @@ export function renderRRuleFields(prefix, existingRule) {
${dayBtns}
-
-
-
-
`;
diff --git a/public/styles/documents.css b/public/styles/documents.css
new file mode 100644
index 0000000..045a88d
--- /dev/null
+++ b/public/styles/documents.css
@@ -0,0 +1,346 @@
+/**
+ * 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-4);
+ top: 50%;
+ width: 18px;
+ height: 18px;
+ transform: translateY(-50%);
+ color: var(--color-text-tertiary);
+ pointer-events: none;
+}
+
+.documents-toolbar__search-input {
+ width: 100%;
+ min-height: var(--target-base);
+ padding: 0 var(--space-3) 0 calc(var(--space-10) + var(--space-1));
+ 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-dropzone {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ min-height: 148px;
+ padding: var(--space-5);
+ border: 1.5px dashed color-mix(in srgb, var(--module-accent) 48%, var(--color-border));
+ border-radius: var(--radius-md);
+ background: color-mix(in srgb, var(--module-accent) 7%, var(--color-surface));
+ color: var(--color-text-secondary);
+ text-align: center;
+ cursor: pointer;
+ transition: border-color var(--transition-fast), background-color var(--transition-fast), transform var(--transition-fast);
+}
+
+.document-dropzone:hover,
+.document-dropzone--active {
+ border-color: var(--module-accent);
+ background: color-mix(in srgb, var(--module-accent) 12%, var(--color-surface));
+}
+
+.document-dropzone--active {
+ transform: translateY(-1px);
+}
+
+.document-dropzone__icon {
+ width: 42px;
+ height: 42px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--radius-md);
+ color: var(--module-accent);
+ background: var(--color-surface);
+ box-shadow: var(--shadow-sm);
+}
+
+.document-dropzone__icon svg {
+ width: 22px;
+ height: 22px;
+}
+
+.document-dropzone__title {
+ font-size: var(--text-sm);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-primary);
+}
+
+.document-dropzone__hint,
+.document-dropzone__file {
+ max-width: 100%;
+ font-size: var(--text-xs);
+ overflow-wrap: anywhere;
+}
+
+.document-dropzone__file {
+ padding: var(--space-1) var(--space-2);
+ border-radius: var(--radius-sm);
+ color: var(--module-accent);
+ background: color-mix(in srgb, var(--module-accent) 14%, transparent);
+ font-weight: var(--font-weight-semibold);
+}
+
+.document-member-picker {
+ margin-top: var(--space-2);
+ 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/layout.css b/public/styles/layout.css
index f58a965..8b75702 100755
--- a/public/styles/layout.css
+++ b/public/styles/layout.css
@@ -1688,6 +1688,7 @@
display: flex;
align-items: flex-end;
gap: var(--space-3);
+ flex-wrap: wrap;
}
.rrule-interval-wrap {
@@ -1702,6 +1703,11 @@
white-space: nowrap;
}
+.rrule-until-field {
+ flex: 1;
+ min-width: 220px;
+}
+
.rrule-day-grid {
display: flex;
gap: var(--space-1);
diff --git a/public/styles/reminders.css b/public/styles/reminders.css
index f9e0d50..7c0018b 100644
--- a/public/styles/reminders.css
+++ b/public/styles/reminders.css
@@ -84,13 +84,11 @@
}
.reminder-fields {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
+ display: flex;
+ flex-direction: column;
gap: var(--space-3, 12px);
}
-@media (max-width: 480px) {
- .reminder-fields {
- grid-template-columns: 1fr;
- }
+.reminder-fields .modal-grid {
+ align-items: end;
}
diff --git a/public/styles/tasks.css b/public/styles/tasks.css
index 663625e..b714bff 100644
--- a/public/styles/tasks.css
+++ b/public/styles/tasks.css
@@ -9,6 +9,10 @@
* -------------------------------------------------------- */
.tasks-page { --module-accent: var(--module-tasks); }
+.modal-panel__body--tasks-fit {
+ flex: 0 1 auto;
+}
+
/* --------------------------------------------------------
* Seiten-Layout
* -------------------------------------------------------- */
@@ -755,4 +759,3 @@
opacity: 0.5;
}
-
diff --git a/public/styles/tokens.css b/public/styles/tokens.css
index 064848f..3dc1a57 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: #1D4ED8;
+ --module-documents: var(--_module-documents); /* Blue - secure family documents */
--_module-settings: #6E7781;
--module-settings: var(--_module-settings); /* Grau - Konfiguration */
--_module-reminders: #0E7490;
diff --git a/public/sw.js b/public/sw.js
index 4b17d5c..1fbec0c 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-v65';
-const PAGES_CACHE = 'oikos-pages-v60';
-const LOCALES_CACHE = 'oikos-locales-v9';
-const ASSETS_CACHE = 'oikos-assets-v60';
+const SHELL_CACHE = 'oikos-shell-v68';
+const PAGES_CACHE = 'oikos-pages-v63';
+const LOCALES_CACHE = 'oikos-locales-v12';
+const ASSETS_CACHE = 'oikos-assets-v63';
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 99c29cb..880b44b 100644
--- a/server/db-schema-test.js
+++ b/server/db-schema-test.js
@@ -349,6 +349,76 @@ const MIGRATIONS_SQL = {
17: `
UPDATE calendar_events SET icon = 'tooth' WHERE icon = 'drill';
`,
+ 18: `
+ CREATE TABLE tasks_new (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL,
+ description TEXT,
+ category TEXT NOT NULL DEFAULT 'Sonstiges',
+ priority TEXT NOT NULL DEFAULT 'none'
+ CHECK(priority IN ('none', 'low', 'medium', 'high', 'urgent')),
+ status TEXT NOT NULL DEFAULT 'open'
+ CHECK(status IN ('open', 'in_progress', 'done', 'archived')),
+ due_date TEXT,
+ due_time TEXT,
+ assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ is_recurring INTEGER NOT NULL DEFAULT 0,
+ recurrence_rule TEXT,
+ parent_task_id INTEGER REFERENCES tasks(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'))
+ );
+
+ INSERT INTO tasks_new
+ SELECT * FROM tasks;
+
+ DROP TABLE tasks;
+ ALTER TABLE tasks_new RENAME TO tasks;
+
+ CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
+ 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 a42d3a7..85892e0 100644
--- a/server/db.js
+++ b/server/db.js
@@ -775,6 +775,84 @@ const MIGRATIONS = [
UPDATE calendar_events SET icon = 'tooth' WHERE icon = 'drill';
`,
},
+ {
+ version: 25,
+ description: 'Allow archived status for tasks',
+ up: `
+ CREATE TABLE tasks_new (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL,
+ description TEXT,
+ category TEXT NOT NULL DEFAULT 'Sonstiges',
+ priority TEXT NOT NULL DEFAULT 'none'
+ CHECK(priority IN ('none', 'low', 'medium', 'high', 'urgent')),
+ status TEXT NOT NULL DEFAULT 'open'
+ CHECK(status IN ('open', 'in_progress', 'done', 'archived')),
+ due_date TEXT,
+ due_time TEXT,
+ assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ is_recurring INTEGER NOT NULL DEFAULT 0,
+ recurrence_rule TEXT,
+ parent_task_id INTEGER REFERENCES tasks(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'))
+ );
+
+ INSERT INTO tasks_new
+ SELECT * FROM tasks;
+
+ DROP TABLE tasks;
+ ALTER TABLE tasks_new RENAME TO tasks;
+
+ CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
+ 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);
+ `,
+ },
+ {
+ 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;
diff --git a/server/routes/tasks.js b/server/routes/tasks.js
index 65afef5..788f9ae 100644
--- a/server/routes/tasks.js
+++ b/server/routes/tasks.js
@@ -19,7 +19,7 @@ const router = express.Router();
// --------------------------------------------------------
const VALID_PRIORITIES = ['none', 'low', 'medium', 'high', 'urgent'];
-const VALID_STATUSES = ['open', 'in_progress', 'done'];
+const VALID_STATUSES = ['open', 'in_progress', 'done', 'archived'];
const VALID_CATEGORIES = ['household', 'school', 'shopping', 'repair',
'health', 'finance', 'leisure', 'misc'];