Merge pull request #104 from rafaelfoster/family-documents-feature

Adding a new Family Documents area for managing family-wide files with privacy controls
This commit is contained in:
ulsklyc
2026-04-29 12:14:12 +02:00
committed by GitHub
31 changed files with 2304 additions and 69 deletions
+18 -2
View File
@@ -105,7 +105,7 @@ function trapFocus(container) {
// -------------------------------------------------------- // --------------------------------------------------------
function serializeForm(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('&'); 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) { if (!force) {
const panel = activeOverlay.querySelector('.modal-panel'); const panel = activeOverlay.querySelector('.modal-panel');
if (panel && isFormDirty(panel)) { if (panel && isFormDirty(panel)) {
const dirtyOverlay = activeOverlay;
const dirtySnapshot = _initialFormSnapshot;
let confirmed; let confirmed;
try { try {
activeOverlay = null;
_isClosing = false;
confirmed = await confirmModal(t('modal.unsavedChanges'), { confirmed = await confirmModal(t('modal.unsavedChanges'), {
danger: false, danger: false,
confirmLabel: t('modal.discardChanges'), confirmLabel: t('modal.discardChanges'),
}); });
} catch (err) { } catch (err) {
activeOverlay = dirtyOverlay;
_initialFormSnapshot = dirtySnapshot;
_isClosing = false; _isClosing = false;
throw err; 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;
} }
} }
+63 -1
View File
@@ -46,7 +46,8 @@
"navigation": "التنقل", "navigation": "التنقل",
"quickActions": "الإجراءات السريعة", "quickActions": "الإجراءات السريعة",
"recipes": "الوصفات", "recipes": "الوصفات",
"more": "المزيد" "more": "المزيد",
"documents": "المستندات"
}, },
"dashboard": { "dashboard": {
"title": "لوحة التحكم", "title": "لوحة التحكم",
@@ -897,5 +898,66 @@
}, },
"emptyHint": { "emptyHint": {
"recipes": "أنشئ وصفات واربطها بمخطط الوجبات." "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}}"
} }
} }
+63 -1
View File
@@ -46,7 +46,8 @@
"navigation": "Navigation", "navigation": "Navigation",
"quickActions": "Schnellaktionen", "quickActions": "Schnellaktionen",
"more": "Mehr", "more": "Mehr",
"recipes": "Rezepte" "recipes": "Rezepte",
"documents": "Dokumente"
}, },
"search": { "search": {
"title": "Suche", "title": "Suche",
@@ -935,5 +936,66 @@
"goCal": "Kalender", "goCal": "Kalender",
"goShop": "Einkaufsliste", "goShop": "Einkaufsliste",
"goNotes": "Notizen" "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}}"
} }
} }
+63 -1
View File
@@ -46,7 +46,8 @@
"navigation": "Πλοήγηση", "navigation": "Πλοήγηση",
"quickActions": "Γρήγορες ενέργειες", "quickActions": "Γρήγορες ενέργειες",
"recipes": "Συνταγές", "recipes": "Συνταγές",
"more": "Περισσότερα" "more": "Περισσότερα",
"documents": "Έγγραφα"
}, },
"dashboard": { "dashboard": {
"title": "Επισκόπηση", "title": "Επισκόπηση",
@@ -897,5 +898,66 @@
}, },
"emptyHint": { "emptyHint": {
"recipes": "Δημιουργήστε συνταγές και συνδέστε τις με τον προγραμματισμό γευμάτων." "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}}"
} }
} }
+69 -2
View File
@@ -46,7 +46,8 @@
"navigation": "Navigation", "navigation": "Navigation",
"quickActions": "Quick actions", "quickActions": "Quick actions",
"recipes": "Recipes", "recipes": "Recipes",
"more": "More" "more": "More",
"documents": "Documents"
}, },
"dashboard": { "dashboard": {
"title": "Overview", "title": "Overview",
@@ -131,6 +132,7 @@
"statusOpen": "Open", "statusOpen": "Open",
"statusInProgress": "In Progress", "statusInProgress": "In Progress",
"statusDone": "Done", "statusDone": "Done",
"statusArchived": "Archived",
"categoryHousehold": "Household", "categoryHousehold": "Household",
"categorySchool": "School", "categorySchool": "School",
"categoryShopping": "Shopping", "categoryShopping": "Shopping",
@@ -152,6 +154,7 @@
"markDone": "Mark {{title}} as done", "markDone": "Mark {{title}} as done",
"markOpen": "Mark {{title}} as open", "markOpen": "Mark {{title}} as open",
"editButton": "Edit task", "editButton": "Edit task",
"archiveButton": "Archive task",
"swipeOpen": "Reopen", "swipeOpen": "Reopen",
"swipeDone": "Done", "swipeDone": "Done",
"swipeEdit": "Edit", "swipeEdit": "Edit",
@@ -162,11 +165,13 @@
"savedToast": "Task saved.", "savedToast": "Task saved.",
"createdToast": "Task created.", "createdToast": "Task created.",
"deletedToast": "Task deleted.", "deletedToast": "Task deleted.",
"archivedToast": "Task archived.",
"loadError": "Task could not be loaded.", "loadError": "Task could not be loaded.",
"subtaskPrompt": "Subtask:", "subtaskPrompt": "Subtask:",
"kanbanOpen": "Open", "kanbanOpen": "Open",
"kanbanInProgress": "In Progress", "kanbanInProgress": "In Progress",
"kanbanDone": "Done", "kanbanDone": "Done",
"kanbanArchived": "Archived",
"kanbanMoveToInProgress": "Set to in progress", "kanbanMoveToInProgress": "Set to in progress",
"kanbanMoveToDone": "Mark as done", "kanbanMoveToDone": "Mark as done",
"kanbanMoveToOpen": "Reopen", "kanbanMoveToOpen": "Reopen",
@@ -179,7 +184,8 @@
"filterGroupPriority": "Priority", "filterGroupPriority": "Priority",
"filterGroupStatus": "Status", "filterGroupStatus": "Status",
"swipedDoneToast": "Marked as done.", "swipedDoneToast": "Marked as done.",
"swipedOpenToast": "Marked as open." "swipedOpenToast": "Marked as open.",
"reminderNeedsDueDate": "Set a due date to enable task reminders."
}, },
"shopping": { "shopping": {
"title": "Shopping", "title": "Shopping",
@@ -916,5 +922,66 @@
"meals": "Plan meals for the week and link recipes.", "meals": "Plan meals for the week and link recipes.",
"birthdays": "Add birthdays — you will receive a reminder in time.", "birthdays": "Add birthdays — you will receive a reminder in time.",
"recipes": "Create recipes and link them to your meal planner." "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}}"
} }
} }
+63 -1
View File
@@ -46,7 +46,8 @@
"navigation": "Navegación", "navigation": "Navegación",
"quickActions": "Acciones rápidas", "quickActions": "Acciones rápidas",
"recipes": "Recetas", "recipes": "Recetas",
"more": "Más" "more": "Más",
"documents": "Documentos"
}, },
"dashboard": { "dashboard": {
"title": "Inicio", "title": "Inicio",
@@ -897,5 +898,66 @@
}, },
"emptyHint": { "emptyHint": {
"recipes": "Crea recetas y vincúlalas con tu planificador de comidas." "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}}"
} }
} }
+63 -1
View File
@@ -46,7 +46,8 @@
"navigation": "Navigation", "navigation": "Navigation",
"quickActions": "Actions rapides", "quickActions": "Actions rapides",
"recipes": "Recettes", "recipes": "Recettes",
"more": "Plus" "more": "Plus",
"documents": "Documents"
}, },
"dashboard": { "dashboard": {
"title": "Accueil", "title": "Accueil",
@@ -897,5 +898,66 @@
}, },
"emptyHint": { "emptyHint": {
"recipes": "Créez des recettes et associez-les à votre planification des repas." "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}}"
} }
} }
+63 -1
View File
@@ -46,7 +46,8 @@
"navigation": "नेविगेशन", "navigation": "नेविगेशन",
"quickActions": "त्वरित क्रियाएं", "quickActions": "त्वरित क्रियाएं",
"recipes": "रेसिपी", "recipes": "रेसिपी",
"more": "और" "more": "और",
"documents": "दस्तावेज़"
}, },
"dashboard": { "dashboard": {
"title": "डैशबोर्ड", "title": "डैशबोर्ड",
@@ -897,5 +898,66 @@
}, },
"emptyHint": { "emptyHint": {
"recipes": "रेसिपी बनाएं और उन्हें अपने भोजन योजनाकार से जोड़ें।" "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}}"
} }
} }
+63 -1
View File
@@ -46,7 +46,8 @@
"navigation": "Navigazione", "navigation": "Navigazione",
"quickActions": "Azioni rapide", "quickActions": "Azioni rapide",
"recipes": "Ricette", "recipes": "Ricette",
"more": "Altro" "more": "Altro",
"documents": "Documenti"
}, },
"dashboard": { "dashboard": {
"title": "Panoramica", "title": "Panoramica",
@@ -897,5 +898,66 @@
}, },
"emptyHint": { "emptyHint": {
"recipes": "Crea ricette e collegale al tuo piano pasti." "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 questarea oppure usa il selettore.",
"selectedFileLabel": "Selezionato: {{name}}"
} }
} }
+63 -1
View File
@@ -46,7 +46,8 @@
"navigation": "ナビゲーション", "navigation": "ナビゲーション",
"quickActions": "クイックアクション", "quickActions": "クイックアクション",
"recipes": "レシピ", "recipes": "レシピ",
"more": "もっと見る" "more": "もっと見る",
"documents": "書類"
}, },
"dashboard": { "dashboard": {
"title": "ダッシュボード", "title": "ダッシュボード",
@@ -897,5 +898,66 @@
}, },
"emptyHint": { "emptyHint": {
"recipes": "レシピを作成して、食事プランに関連付けましょう。" "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}}"
} }
} }
+69 -2
View File
@@ -46,7 +46,8 @@
"navigation": "Navegação", "navigation": "Navegação",
"quickActions": "Ações rápidas", "quickActions": "Ações rápidas",
"recipes": "Receitas", "recipes": "Receitas",
"more": "Mais" "more": "Mais",
"documents": "Documentos"
}, },
"dashboard": { "dashboard": {
"title": "Painel", "title": "Painel",
@@ -131,6 +132,7 @@
"statusOpen": "Aberto", "statusOpen": "Aberto",
"statusInProgress": "Em andamento", "statusInProgress": "Em andamento",
"statusDone": "Concluído", "statusDone": "Concluído",
"statusArchived": "Arquivado",
"categoryHousehold": "Casa", "categoryHousehold": "Casa",
"categorySchool": "Escola", "categorySchool": "Escola",
"categoryShopping": "Compras", "categoryShopping": "Compras",
@@ -152,6 +154,7 @@
"markDone": "Marcar {{title}} como concluído", "markDone": "Marcar {{title}} como concluído",
"markOpen": "Marcar {{title}} como pendente", "markOpen": "Marcar {{title}} como pendente",
"editButton": "Editar tarefa", "editButton": "Editar tarefa",
"archiveButton": "Arquivar tarefa",
"swipeOpen": "Abrir", "swipeOpen": "Abrir",
"swipeDone": "Concluído", "swipeDone": "Concluído",
"swipeEdit": "Editar", "swipeEdit": "Editar",
@@ -162,11 +165,13 @@
"savedToast": "Tarefa salva.", "savedToast": "Tarefa salva.",
"createdToast": "Tarefa criada.", "createdToast": "Tarefa criada.",
"deletedToast": "Tarefa excluída.", "deletedToast": "Tarefa excluída.",
"archivedToast": "Tarefa arquivada.",
"loadError": "Falha ao carregar a tarefa.", "loadError": "Falha ao carregar a tarefa.",
"subtaskPrompt": "Subtarefa:", "subtaskPrompt": "Subtarefa:",
"kanbanOpen": "Aberto", "kanbanOpen": "Aberto",
"kanbanInProgress": "Em andamento", "kanbanInProgress": "Em andamento",
"kanbanDone": "Concluído", "kanbanDone": "Concluído",
"kanbanArchived": "Arquivado",
"kanbanMoveToInProgress": "Mover para em andamento", "kanbanMoveToInProgress": "Mover para em andamento",
"kanbanMoveToDone": "Marcar como concluído", "kanbanMoveToDone": "Marcar como concluído",
"kanbanMoveToOpen": "Reabrir", "kanbanMoveToOpen": "Reabrir",
@@ -179,7 +184,8 @@
"filterGroupPriority": "Prioridade", "filterGroupPriority": "Prioridade",
"filterGroupStatus": "Estado", "filterGroupStatus": "Estado",
"swipedDoneToast": "Marcado como concluído.", "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": { "shopping": {
"title": "Compras", "title": "Compras",
@@ -898,5 +904,66 @@
}, },
"emptyHint": { "emptyHint": {
"recipes": "Crie receitas e vincule-as ao seu planejador de refeições." "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}}"
} }
} }
+63 -1
View File
@@ -46,7 +46,8 @@
"navigation": "Навигация", "navigation": "Навигация",
"quickActions": "Быстрые действия", "quickActions": "Быстрые действия",
"recipes": "Рецепты", "recipes": "Рецепты",
"more": "Ещё" "more": "Ещё",
"documents": "Документы"
}, },
"dashboard": { "dashboard": {
"title": "Обзор", "title": "Обзор",
@@ -897,5 +898,66 @@
}, },
"emptyHint": { "emptyHint": {
"recipes": "Создавайте рецепты и связывайте их с вашим планом питания." "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}}"
} }
} }
+63 -1
View File
@@ -46,7 +46,8 @@
"navigation": "Navigering", "navigation": "Navigering",
"quickActions": "Snabba åtgärder", "quickActions": "Snabba åtgärder",
"recipes": "Recept", "recipes": "Recept",
"more": "Mer" "more": "Mer",
"documents": "Dokument"
}, },
"dashboard": { "dashboard": {
"title": "Översikt", "title": "Översikt",
@@ -897,5 +898,66 @@
}, },
"emptyHint": { "emptyHint": {
"recipes": "Skapa recept och koppla dem till din måltidsplanering." "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}}"
} }
} }
+63 -1
View File
@@ -46,7 +46,8 @@
"navigation": "Gezinme", "navigation": "Gezinme",
"quickActions": "Hızlı işlemler", "quickActions": "Hızlı işlemler",
"recipes": "Tarifler", "recipes": "Tarifler",
"more": "Daha Fazla" "more": "Daha Fazla",
"documents": "Belgeler"
}, },
"dashboard": { "dashboard": {
"title": "Genel Bakış", "title": "Genel Bakış",
@@ -897,5 +898,66 @@
}, },
"emptyHint": { "emptyHint": {
"recipes": "Tarifler oluşturun ve yemek planlayıcınıza bağlayın." "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}}"
} }
} }
+63 -1
View File
@@ -46,7 +46,8 @@
"navigation": "Навігація", "navigation": "Навігація",
"quickActions": "Швидкі дії", "quickActions": "Швидкі дії",
"recipes": "Рецепти", "recipes": "Рецепти",
"more": "Більше" "more": "Більше",
"documents": "Документи"
}, },
"dashboard": { "dashboard": {
"title": "Огляд", "title": "Огляд",
@@ -905,5 +906,66 @@
"meals": "Плануйте харчування на тиждень і пов'язуйте рецепти.", "meals": "Плануйте харчування на тиждень і пов'язуйте рецепти.",
"birthdays": "Додайте дні народження — ви отримаєте нагадування завчасно.", "birthdays": "Додайте дні народження — ви отримаєте нагадування завчасно.",
"recipes": "Створюйте рецепти та пов'язуйте їх із планувальником харчування." "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}}"
} }
} }
+63 -1
View File
@@ -46,7 +46,8 @@
"navigation": "导航", "navigation": "导航",
"quickActions": "快捷操作", "quickActions": "快捷操作",
"recipes": "食谱", "recipes": "食谱",
"more": "更多" "more": "更多",
"documents": "文档"
}, },
"dashboard": { "dashboard": {
"title": "概览", "title": "概览",
@@ -897,5 +898,66 @@
}, },
"emptyHint": { "emptyHint": {
"recipes": "创建食谱并将其关联到你的膳食计划。" "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}}"
} }
} }
+421
View File
@@ -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 = `
<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>
<label class="document-dropzone" id="document-dropzone" for="document-file">
<input class="sr-only" id="document-file" type="file" required>
<span class="document-dropzone__icon">
<i data-lucide="file-up" aria-hidden="true"></i>
</span>
<span class="document-dropzone__title">${t('documents.dropzoneTitle')}</span>
<span class="document-dropzone__hint">${t('documents.dropzoneHint')}</span>
<span class="document-dropzone__file" id="document-selected-file" hidden></span>
</label>
<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();
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`;
}
+103 -17
View File
@@ -30,6 +30,7 @@ const STATUSES = () => [
{ value: 'open', label: t('tasks.statusOpen') }, { value: 'open', label: t('tasks.statusOpen') },
{ value: 'in_progress', label: t('tasks.statusInProgress') }, { value: 'in_progress', label: t('tasks.statusInProgress') },
{ value: 'done', label: t('tasks.statusDone') }, { value: 'done', label: t('tasks.statusDone') },
{ value: 'archived', label: t('tasks.statusArchived') },
]; ];
const CATEGORIES = [ const CATEGORIES = [
@@ -208,6 +209,11 @@ function renderTaskCard(task, opts = {}) {
aria-label="${t('tasks.editButton')}"> aria-label="${t('tasks.editButton')}">
<i data-lucide="pencil" class="icon-base" aria-hidden="true"></i> <i data-lucide="pencil" class="icon-base" aria-hidden="true"></i>
</button> </button>
${task.status !== 'archived' ? `
<button class="btn btn--ghost btn--icon btn--icon-sm" data-action="archive-task" data-id="${task.id}"
aria-label="${t('tasks.archiveButton')}">
<i data-lucide="archive" class="icon-base" aria-hidden="true"></i>
</button>` : ''}
</div> </div>
${progress !== null ? ` ${progress !== null ? `
@@ -376,7 +382,7 @@ function renderModalContent({ task = null, users = [], reminder = null } = {}) {
${renderRRuleFields('task', task?.recurrence_rule)} ${renderRRuleFields('task', task?.recurrence_rule)}
${renderReminderSection(reminder)} ${renderReminderSection(task, reminder)}
<div id="task-form-error" class="login-error" hidden></div> <div id="task-form-error" class="login-error" hidden></div>
@@ -446,10 +452,36 @@ async function loadReminderForTask(taskId) {
} }
} }
function renderReminderSection(reminder = null) { function parseOffsetMsFromReminder(task, reminder) {
const hasReminder = !!reminder; if (!task?.due_date || !reminder?.remind_at) return null;
const remindDate = hasReminder ? reminder.remind_at.slice(0, 10) : ''; const due = task.due_time ? new Date(`${task.due_date}T${task.due_time}`) : new Date(`${task.due_date}T23:59:59`);
const remindTime = hasReminder ? reminder.remind_at.slice(11, 16) : ''; 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 ` return `
<div class="reminder-section"> <div class="reminder-section">
@@ -462,12 +494,33 @@ function renderReminderSection(reminder = null) {
</div> </div>
<div id="reminder-fields" class="reminder-fields" ${hasReminder ? '' : 'style="display:none"'}> <div id="reminder-fields" class="reminder-fields" ${hasReminder ? '' : 'style="display:none"'}>
<div class="form-group" style="margin:0"> <div class="form-group" style="margin:0">
<label class="label" for="reminder-date">${t('reminders.dateLabel')}</label> <label class="label" for="reminder-offset">${t('reminders.offsetLabel')}</label>
<input class="input js-date-input" type="text" id="reminder-date" value="${formatDateInput(remindDate)}" placeholder="${dateInputPlaceholder()}" inputmode="numeric"> <select class="input" id="reminder-offset">
<option value="offset_none">${t('reminders.offsetNone')}</option>
<option value="offset_at_time" ${resolved.preset === 'offset_at_time' ? 'selected' : ''}>${t('reminders.offsetAtTime')}</option>
<option value="offset_15m" ${resolved.preset === 'offset_15m' ? 'selected' : ''}>${t('reminders.offset15min')}</option>
<option value="offset_1h" ${resolved.preset === 'offset_1h' ? 'selected' : ''}>${t('reminders.offset1hour')}</option>
<option value="offset_1d" ${resolved.preset === 'offset_1d' ? 'selected' : ''}>${t('reminders.offset1day')}</option>
<option value="offset_2d" ${resolved.preset === 'offset_2d' ? 'selected' : ''}>${t('reminders.offset2days')}</option>
<option value="offset_1w" ${resolved.preset === 'offset_1w' ? 'selected' : ''}>${t('reminders.offset1week')}</option>
<option value="offset_2w" ${resolved.preset === 'offset_2w' ? 'selected' : ''}>${t('reminders.offset2weeks')}</option>
<option value="offset_custom" ${resolved.preset === 'offset_custom' ? 'selected' : ''}>${t('reminders.offsetCustom')}</option>
</select>
</div> </div>
<div class="form-group" style="margin:0"> <div class="modal-grid modal-grid--2" id="reminder-custom-fields" style="${showCustom ? '' : 'display:none'};margin-top:var(--space-3)">
<label class="label" for="reminder-time">${t('reminders.timeLabel')}</label> <div class="form-group" style="margin:0">
<input class="input" type="time" id="reminder-time" value="${remindTime || '08:00'}"> <label class="label" for="reminder-custom-amount">${t('reminders.customAmountLabel')}</label>
<input class="input" type="number" min="1" step="1" id="reminder-custom-amount" value="${resolved.amount}">
</div>
<div class="form-group" style="margin:0">
<label class="label" for="reminder-custom-unit">${t('reminders.customUnitLabel')}</label>
<select class="input" id="reminder-custom-unit">
<option value="minutes" ${resolved.unit === 'minutes' ? 'selected' : ''}>${t('reminders.customMinutes')}</option>
<option value="hours" ${resolved.unit === 'hours' ? 'selected' : ''}>${t('reminders.customHours')}</option>
<option value="days" ${resolved.unit === 'days' ? 'selected' : ''}>${t('reminders.customDays')}</option>
<option value="weeks" ${resolved.unit === 'weeks' ? 'selected' : ''}>${t('reminders.customWeeks')}</option>
</select>
</div>
</div> </div>
</div> </div>
</div>`; </div>`;
@@ -484,6 +537,7 @@ function openTaskModal({ task = null, users = [], reminder = null } = {}, contai
content: renderModalContent({ task, users, reminder }), content: renderModalContent({ task, users, reminder }),
size: 'lg', size: 'lg',
onSave(panel) { onSave(panel) {
panel.querySelector('.modal-panel__body')?.classList.add('modal-panel__body--tasks-fit');
// RRULE-Events binden // RRULE-Events binden
bindRRuleEvents(document, 'task'); bindRRuleEvents(document, 'task');
@@ -493,9 +547,15 @@ function openTaskModal({ task = null, users = [], reminder = null } = {}, contai
// Reminder-Toggle: Felder ein-/ausblenden // Reminder-Toggle: Felder ein-/ausblenden
const toggle = panel.querySelector('#reminder-toggle'); const toggle = panel.querySelector('#reminder-toggle');
const fields = panel.querySelector('#reminder-fields'); const fields = panel.querySelector('#reminder-fields');
const offset = panel.querySelector('#reminder-offset');
const customFields = panel.querySelector('#reminder-custom-fields');
toggle?.addEventListener('change', () => { toggle?.addEventListener('change', () => {
fields.style.display = toggle.checked ? '' : 'none'; 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) => { panel.querySelectorAll('.js-date-input').forEach((input) => {
input.addEventListener('blur', () => { input.addEventListener('blur', () => {
const parsed = parseDateInput(input.value); const parsed = parseDateInput(input.value);
@@ -537,9 +597,7 @@ async function handleFormSubmit(e, container) {
const dueDate = parseDateInput(dueDateRaw); const dueDate = parseDateInput(dueDateRaw);
const rrule = getRRuleValues(document, 'task'); const rrule = getRRuleValues(document, 'task');
const reminderToggle = form.querySelector('#reminder-toggle'); const reminderToggle = form.querySelector('#reminder-toggle');
const reminderDateRaw = form.querySelector('#reminder-date')?.value || ''; if (!isDateInputValid(dueDateRaw) || !rrule.valid_until) {
const reminderDate = parseDateInput(reminderDateRaw);
if (!isDateInputValid(dueDateRaw) || !rrule.valid_until || (reminderToggle?.checked && !isDateInputValid(reminderDateRaw))) {
errorEl.textContent = t('calendar.invalidDate'); errorEl.textContent = t('calendar.invalidDate');
errorEl.hidden = false; errorEl.hidden = false;
submitBtn.disabled = false; submitBtn.disabled = false;
@@ -572,10 +630,27 @@ async function handleFormSubmit(e, container) {
// Erinnerung speichern oder löschen // Erinnerung speichern oder löschen
if (savedTaskId) { if (savedTaskId) {
const reminderTime = form.querySelector('#reminder-time')?.value || '08:00'; if (reminderToggle?.checked) {
if (!dueDate) throw new Error(t('tasks.reminderNeedsDueDate'));
if (reminderToggle?.checked && reminderDate) { const dueDateTime = body.due_time ? new Date(`${dueDate}T${body.due_time}`) : new Date(`${dueDate}T23:59:59`);
const remindAt = `${reminderDate}T${reminderTime}`; 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 }); await api.post('/reminders', { entity_type: 'task', entity_id: savedTaskId, remind_at: remindAt });
refreshReminders(); refreshReminders();
} else if (!reminderToggle?.checked) { } else if (!reminderToggle?.checked) {
@@ -643,6 +718,7 @@ const KANBAN_COLS = () => [
{ status: 'open', label: t('tasks.kanbanOpen'), colorVar: '--color-text-secondary' }, { status: 'open', label: t('tasks.kanbanOpen'), colorVar: '--color-text-secondary' },
{ status: 'in_progress', label: t('tasks.kanbanInProgress'), colorVar: '--color-warning' }, { status: 'in_progress', label: t('tasks.kanbanInProgress'), colorVar: '--color-warning' },
{ status: 'done', label: t('tasks.kanbanDone'), colorVar: '--color-success' }, { status: 'done', label: t('tasks.kanbanDone'), colorVar: '--color-success' },
{ status: 'archived', label: t('tasks.kanbanArchived'), colorVar: '--color-text-tertiary' },
]; ];
function kanbanNextStatus(status) { 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') { if (action === 'add-subtask') {
await handleAddSubtask(target.dataset.parent, container); await handleAddSubtask(target.dataset.parent, container);
} }
+4 -1
View File
@@ -25,6 +25,7 @@ const ROUTES = [
{ path: '/recipes', page: '/pages/recipes.js', requiresAuth: true, module: 'recipes' }, { path: '/recipes', page: '/pages/recipes.js', requiresAuth: true, module: 'recipes' },
{ path: '/contacts', page: '/pages/contacts.js', requiresAuth: true, module: 'contacts' }, { path: '/contacts', page: '/pages/contacts.js', requiresAuth: true, module: 'contacts' },
{ path: '/budget', page: '/pages/budget.js', requiresAuth: true, module: 'budget' }, { 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' }, { 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', const ROUTE_ORDER = ['/', '/tasks', '/calendar', '/birthdays', '/meals', '/recipes', '/shopping',
'/notes', '/contacts', '/budget', '/settings']; '/notes', '/contacts', '/budget', '/documents', '/settings'];
const PRIMARY_NAV = 4; const PRIMARY_NAV = 4;
@@ -181,6 +182,7 @@ function routeTitle(path) {
'/notes': t('nav.notes'), '/notes': t('nav.notes'),
'/contacts': t('nav.contacts'), '/contacts': t('nav.contacts'),
'/budget': t('nav.budget'), '/budget': t('nav.budget'),
'/documents': t('nav.documents'),
'/settings': t('nav.settings'), '/settings': t('nav.settings'),
}; };
return map[path] || getAppName(); return map[path] || getAppName();
@@ -886,6 +888,7 @@ function navItems() {
{ path: '/notes', label: t('nav.notes'), icon: 'sticky-note' }, { path: '/notes', label: t('nav.notes'), icon: 'sticky-note' },
{ path: '/contacts', label: t('nav.contacts'), icon: 'book-user' }, { path: '/contacts', label: t('nav.contacts'), icon: 'book-user' },
{ path: '/budget', label: t('nav.budget'), icon: 'wallet' }, { 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' }, { path: '/settings', label: t('nav.settings'), icon: 'settings' },
]; ];
} }
+5 -5
View File
@@ -106,6 +106,11 @@ export function renderRRuleFields(prefix, existingRule) {
<span class="rrule-interval-unit" id="${prefix}-rrule-unit">${unitLabel(parsed.freq, parsed.interval)}</span> <span class="rrule-interval-unit" id="${prefix}-rrule-unit">${unitLabel(parsed.freq, parsed.interval)}</span>
</div> </div>
</div> </div>
<div class="form-group rrule-until-field" style="margin-bottom:0">
<label class="label form-label" for="${prefix}-rrule-until">${t('rrule.labelUntil')}</label>
<input class="input form-input js-date-input" type="text" id="${prefix}-rrule-until"
value="${formatDateInput(parsed.until)}" placeholder="${dateInputPlaceholder()}" inputmode="numeric">
</div>
</div> </div>
<div class="rrule-weekdays" id="${prefix}-rrule-weekdays" ${parsed.freq === 'WEEKLY' ? '' : 'hidden'}> <div class="rrule-weekdays" id="${prefix}-rrule-weekdays" ${parsed.freq === 'WEEKLY' ? '' : 'hidden'}>
@@ -113,11 +118,6 @@ export function renderRRuleFields(prefix, existingRule) {
<div class="rrule-day-grid">${dayBtns}</div> <div class="rrule-day-grid">${dayBtns}</div>
</div> </div>
<div class="form-group" style="margin-top:var(--space-3)">
<label class="label form-label" for="${prefix}-rrule-until">${t('rrule.labelUntil')}</label>
<input class="input form-input js-date-input" type="text" id="${prefix}-rrule-until"
value="${formatDateInput(parsed.until)}" placeholder="${dateInputPlaceholder()}" inputmode="numeric">
</div>
</div> </div>
</div> </div>
`; `;
+346
View File
@@ -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;
}
}
+6
View File
@@ -1688,6 +1688,7 @@
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
gap: var(--space-3); gap: var(--space-3);
flex-wrap: wrap;
} }
.rrule-interval-wrap { .rrule-interval-wrap {
@@ -1702,6 +1703,11 @@
white-space: nowrap; white-space: nowrap;
} }
.rrule-until-field {
flex: 1;
min-width: 220px;
}
.rrule-day-grid { .rrule-day-grid {
display: flex; display: flex;
gap: var(--space-1); gap: var(--space-1);
+4 -6
View File
@@ -84,13 +84,11 @@
} }
.reminder-fields { .reminder-fields {
display: grid; display: flex;
grid-template-columns: repeat(2, minmax(0, 1fr)); flex-direction: column;
gap: var(--space-3, 12px); gap: var(--space-3, 12px);
} }
@media (max-width: 480px) { .reminder-fields .modal-grid {
.reminder-fields { align-items: end;
grid-template-columns: 1fr;
}
} }
+4 -1
View File
@@ -9,6 +9,10 @@
* -------------------------------------------------------- */ * -------------------------------------------------------- */
.tasks-page { --module-accent: var(--module-tasks); } .tasks-page { --module-accent: var(--module-tasks); }
.modal-panel__body--tasks-fit {
flex: 0 1 auto;
}
/* -------------------------------------------------------- /* --------------------------------------------------------
* Seiten-Layout * Seiten-Layout
* -------------------------------------------------------- */ * -------------------------------------------------------- */
@@ -755,4 +759,3 @@
opacity: 0.5; opacity: 0.5;
} }
+2
View File
@@ -172,6 +172,8 @@
--module-birthdays: var(--_module-birthdays); /* Rose - Geburtstage */ --module-birthdays: var(--_module-birthdays); /* Rose - Geburtstage */
--_module-budget: #0F766E; --_module-budget: #0F766E;
--module-budget: var(--_module-budget); /* Teal-700 - Finanzen, Stabilität */ --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: #6E7781;
--module-settings: var(--_module-settings); /* Grau - Konfiguration */ --module-settings: var(--_module-settings); /* Grau - Konfiguration */
--_module-reminders: #0E7490; --_module-reminders: #0E7490;
+6 -4
View File
@@ -13,10 +13,10 @@
* → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit) * → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit)
*/ */
const SHELL_CACHE = 'oikos-shell-v65'; const SHELL_CACHE = 'oikos-shell-v68';
const PAGES_CACHE = 'oikos-pages-v60'; const PAGES_CACHE = 'oikos-pages-v63';
const LOCALES_CACHE = 'oikos-locales-v9'; const LOCALES_CACHE = 'oikos-locales-v12';
const ASSETS_CACHE = 'oikos-assets-v60'; const ASSETS_CACHE = 'oikos-assets-v63';
const BYPASS_CACHE = 'oikos-bypass-flag'; const BYPASS_CACHE = 'oikos-bypass-flag';
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, LOCALES_CACHE, ASSETS_CACHE]; const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, LOCALES_CACHE, ASSETS_CACHE];
@@ -47,6 +47,7 @@ const APP_SHELL = [
'/styles/contacts.css', '/styles/contacts.css',
'/styles/birthdays.css', '/styles/birthdays.css',
'/styles/budget.css', '/styles/budget.css',
'/styles/documents.css',
'/styles/settings.css', '/styles/settings.css',
'/styles/recipes.css', '/styles/recipes.css',
'/components/oikos-install-prompt.js', '/components/oikos-install-prompt.js',
@@ -90,6 +91,7 @@ const PAGE_MODULES = [
'/pages/contacts.js', '/pages/contacts.js',
'/pages/birthdays.js', '/pages/birthdays.js',
'/pages/budget.js', '/pages/budget.js',
'/pages/documents.js',
'/pages/settings.js', '/pages/settings.js',
'/pages/login.js', '/pages/login.js',
'/pages/recipes.js', '/pages/recipes.js',
+70
View File
@@ -349,6 +349,76 @@ const MIGRATIONS_SQL = {
17: ` 17: `
UPDATE calendar_events SET icon = 'tooth' WHERE icon = 'drill'; 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 }; export { MIGRATIONS_SQL };
+78
View File
@@ -775,6 +775,84 @@ const MIGRATIONS = [
UPDATE calendar_events SET icon = 'tooth' WHERE icon = 'drill'; 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);
`,
},
]; ];
/** /**
+2
View File
@@ -27,6 +27,7 @@ import notesRouter from './routes/notes.js';
import contactsRouter from './routes/contacts.js'; import contactsRouter from './routes/contacts.js';
import birthdaysRouter from './routes/birthdays.js'; import birthdaysRouter from './routes/birthdays.js';
import budgetRouter from './routes/budget.js'; import budgetRouter from './routes/budget.js';
import documentsRouter from './routes/documents.js';
import weatherRouter from './routes/weather.js'; import weatherRouter from './routes/weather.js';
import preferencesRouter from './routes/preferences.js'; import preferencesRouter from './routes/preferences.js';
import remindersRouter from './routes/reminders.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/contacts', contactsRouter);
app.use('/api/v1/birthdays', birthdaysRouter); app.use('/api/v1/birthdays', birthdaysRouter);
app.use('/api/v1/budget', budgetRouter); app.use('/api/v1/budget', budgetRouter);
app.use('/api/v1/documents', documentsRouter);
app.use('/api/v1/weather', weatherRouter); app.use('/api/v1/weather', weatherRouter);
app.use('/api/v1/preferences', preferencesRouter); app.use('/api/v1/preferences', preferencesRouter);
app.use('/api/v1/reminders', remindersRouter); 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;
+1 -1
View File
@@ -19,7 +19,7 @@ const router = express.Router();
// -------------------------------------------------------- // --------------------------------------------------------
const VALID_PRIORITIES = ['none', 'low', 'medium', 'high', 'urgent']; 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', const VALID_CATEGORIES = ['household', 'school', 'shopping', 'repair',
'health', 'finance', 'leisure', 'misc']; 'health', 'finance', 'leisure', 'misc'];