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:
@@ -105,7 +105,7 @@ function trapFocus(container) {
|
||||
// --------------------------------------------------------
|
||||
|
||||
function serializeForm(container) {
|
||||
const inputs = container.querySelectorAll('input, select, textarea');
|
||||
const inputs = container.querySelectorAll('input:not([type="file"]), select, textarea');
|
||||
return Array.from(inputs).map((el) => `${el.name || el.id}=${el.value}`).join('&');
|
||||
}
|
||||
|
||||
@@ -327,17 +327,33 @@ export async function closeModal({ force = false } = {}) {
|
||||
if (!force) {
|
||||
const panel = activeOverlay.querySelector('.modal-panel');
|
||||
if (panel && isFormDirty(panel)) {
|
||||
const dirtyOverlay = activeOverlay;
|
||||
const dirtySnapshot = _initialFormSnapshot;
|
||||
let confirmed;
|
||||
try {
|
||||
activeOverlay = null;
|
||||
_isClosing = false;
|
||||
confirmed = await confirmModal(t('modal.unsavedChanges'), {
|
||||
danger: false,
|
||||
confirmLabel: t('modal.discardChanges'),
|
||||
});
|
||||
} catch (err) {
|
||||
activeOverlay = dirtyOverlay;
|
||||
_initialFormSnapshot = dirtySnapshot;
|
||||
_isClosing = false;
|
||||
throw err;
|
||||
}
|
||||
if (!confirmed) { _isClosing = false; return; }
|
||||
activeOverlay = dirtyOverlay;
|
||||
_initialFormSnapshot = dirtySnapshot;
|
||||
if (!confirmed) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
if (window.oikos?.setThemeColor) {
|
||||
window.oikos.setThemeColor(OVERLAY_THEME_COLOR, OVERLAY_THEME_COLOR);
|
||||
}
|
||||
_isClosing = false;
|
||||
return;
|
||||
}
|
||||
_isClosing = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+64
-2
@@ -46,7 +46,8 @@
|
||||
"navigation": "التنقل",
|
||||
"quickActions": "الإجراءات السريعة",
|
||||
"recipes": "الوصفات",
|
||||
"more": "المزيد"
|
||||
"more": "المزيد",
|
||||
"documents": "المستندات"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "لوحة التحكم",
|
||||
@@ -897,5 +898,66 @@
|
||||
},
|
||||
"emptyHint": {
|
||||
"recipes": "أنشئ وصفات واربطها بمخطط الوجبات."
|
||||
},
|
||||
"documents": {
|
||||
"title": "المستندات",
|
||||
"addButton": "إضافة مستند",
|
||||
"searchPlaceholder": "البحث في المستندات...",
|
||||
"gridView": "Grid view",
|
||||
"listView": "List view",
|
||||
"viewToggle": "Document view",
|
||||
"allCategories": "كل الفئات",
|
||||
"emptyTitle": "لا توجد مستندات بعد",
|
||||
"emptyDescription": "ارفع مستندات العائلة وتحكم في من يمكنه رؤية كل ملف.",
|
||||
"newTitle": "مستند جديد",
|
||||
"editTitle": "إعدادات المستند",
|
||||
"nameLabel": "Name",
|
||||
"descriptionLabel": "Description",
|
||||
"categoryLabel": "Category",
|
||||
"fileLabel": "File",
|
||||
"fileHint": "PDF, images, text and Office files up to 5 MB.",
|
||||
"visibilityLabel": "Visibility",
|
||||
"statusLabel": "Status",
|
||||
"allowedMembersLabel": "Allowed members",
|
||||
"uploadAction": "Upload",
|
||||
"downloadAction": "Download",
|
||||
"editAction": "Settings",
|
||||
"archiveAction": "Archive",
|
||||
"restoreAction": "Restore",
|
||||
"savedToast": "Document saved.",
|
||||
"uploadedToast": "Document uploaded.",
|
||||
"archivedToast": "Document archived.",
|
||||
"restoredToast": "Document restored.",
|
||||
"deletedToast": "Document deleted.",
|
||||
"deleteConfirm": "Delete document \"{{name}}\"?",
|
||||
"fileRequired": "Select a file to upload.",
|
||||
"fileTooLarge": "File may be at most 5 MB.",
|
||||
"fileReadError": "File could not be read.",
|
||||
"statusActive": "Active",
|
||||
"statusArchived": "Archived",
|
||||
"visibility": {
|
||||
"family": "كل العائلة",
|
||||
"restricted": "أعضاء محددون",
|
||||
"private": "أنا فقط"
|
||||
},
|
||||
"category": {
|
||||
"medical": "طبي",
|
||||
"school": "مدرسة",
|
||||
"identity": "هوية",
|
||||
"insurance": "تأمين",
|
||||
"finance": "مالية",
|
||||
"home": "منزل",
|
||||
"vehicle": "مركبة",
|
||||
"legal": "قانوني",
|
||||
"travel": "سفر",
|
||||
"pets": "حيوانات أليفة",
|
||||
"warranty": "ضمان",
|
||||
"taxes": "ضرائب",
|
||||
"work": "عمل",
|
||||
"other": "أخرى"
|
||||
},
|
||||
"dropzoneTitle": "أفلت الملف هنا أو انقر للاختيار",
|
||||
"dropzoneHint": "اسحب ملفًا إلى هذه المنطقة أو استخدم محدد الملفات.",
|
||||
"selectedFileLabel": "المحدد: {{name}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+64
-2
@@ -46,7 +46,8 @@
|
||||
"navigation": "Navigation",
|
||||
"quickActions": "Schnellaktionen",
|
||||
"more": "Mehr",
|
||||
"recipes": "Rezepte"
|
||||
"recipes": "Rezepte",
|
||||
"documents": "Dokumente"
|
||||
},
|
||||
"search": {
|
||||
"title": "Suche",
|
||||
@@ -935,5 +936,66 @@
|
||||
"goCal": "Kalender",
|
||||
"goShop": "Einkaufsliste",
|
||||
"goNotes": "Notizen"
|
||||
},
|
||||
"documents": {
|
||||
"title": "Dokumente",
|
||||
"addButton": "Dokument hinzufügen",
|
||||
"searchPlaceholder": "Dokumente suchen...",
|
||||
"gridView": "Grid view",
|
||||
"listView": "List view",
|
||||
"viewToggle": "Document view",
|
||||
"allCategories": "Alle Kategorien",
|
||||
"emptyTitle": "Noch keine Dokumente",
|
||||
"emptyDescription": "Lade Familiendokumente hoch und steuere, wer jede Datei sehen darf.",
|
||||
"newTitle": "Neues Dokument",
|
||||
"editTitle": "Dokumenteinstellungen",
|
||||
"nameLabel": "Name",
|
||||
"descriptionLabel": "Description",
|
||||
"categoryLabel": "Category",
|
||||
"fileLabel": "File",
|
||||
"fileHint": "PDF, images, text and Office files up to 5 MB.",
|
||||
"visibilityLabel": "Visibility",
|
||||
"statusLabel": "Status",
|
||||
"allowedMembersLabel": "Allowed members",
|
||||
"uploadAction": "Upload",
|
||||
"downloadAction": "Download",
|
||||
"editAction": "Settings",
|
||||
"archiveAction": "Archive",
|
||||
"restoreAction": "Restore",
|
||||
"savedToast": "Document saved.",
|
||||
"uploadedToast": "Document uploaded.",
|
||||
"archivedToast": "Document archived.",
|
||||
"restoredToast": "Document restored.",
|
||||
"deletedToast": "Document deleted.",
|
||||
"deleteConfirm": "Delete document \"{{name}}\"?",
|
||||
"fileRequired": "Select a file to upload.",
|
||||
"fileTooLarge": "File may be at most 5 MB.",
|
||||
"fileReadError": "File could not be read.",
|
||||
"statusActive": "Active",
|
||||
"statusArchived": "Archived",
|
||||
"visibility": {
|
||||
"family": "Ganze Familie",
|
||||
"restricted": "Ausgewählte Mitglieder",
|
||||
"private": "Nur ich"
|
||||
},
|
||||
"category": {
|
||||
"medical": "Medizin",
|
||||
"school": "Schule",
|
||||
"identity": "Identität",
|
||||
"insurance": "Versicherung",
|
||||
"finance": "Finanzen",
|
||||
"home": "Zuhause",
|
||||
"vehicle": "Fahrzeug",
|
||||
"legal": "Rechtliches",
|
||||
"travel": "Reisen",
|
||||
"pets": "Haustiere",
|
||||
"warranty": "Garantie",
|
||||
"taxes": "Steuern",
|
||||
"work": "Arbeit",
|
||||
"other": "Sonstiges"
|
||||
},
|
||||
"dropzoneTitle": "Datei hier ablegen oder klicken",
|
||||
"dropzoneHint": "Ziehe eine Datei in diesen Bereich oder nutze die Dateiauswahl.",
|
||||
"selectedFileLabel": "Ausgewählt: {{name}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+64
-2
@@ -46,7 +46,8 @@
|
||||
"navigation": "Πλοήγηση",
|
||||
"quickActions": "Γρήγορες ενέργειες",
|
||||
"recipes": "Συνταγές",
|
||||
"more": "Περισσότερα"
|
||||
"more": "Περισσότερα",
|
||||
"documents": "Έγγραφα"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Επισκόπηση",
|
||||
@@ -897,5 +898,66 @@
|
||||
},
|
||||
"emptyHint": {
|
||||
"recipes": "Δημιουργήστε συνταγές και συνδέστε τις με τον προγραμματισμό γευμάτων."
|
||||
},
|
||||
"documents": {
|
||||
"title": "Έγγραφα",
|
||||
"addButton": "Προσθήκη εγγράφου",
|
||||
"searchPlaceholder": "Αναζήτηση εγγράφων...",
|
||||
"gridView": "Grid view",
|
||||
"listView": "List view",
|
||||
"viewToggle": "Document view",
|
||||
"allCategories": "Όλες οι κατηγορίες",
|
||||
"emptyTitle": "Δεν υπάρχουν έγγραφα ακόμα",
|
||||
"emptyDescription": "Ανεβάστε οικογενειακά έγγραφα και ελέγξτε ποιος μπορεί να βλέπει κάθε αρχείο.",
|
||||
"newTitle": "Νέο έγγραφο",
|
||||
"editTitle": "Ρυθμίσεις εγγράφου",
|
||||
"nameLabel": "Name",
|
||||
"descriptionLabel": "Description",
|
||||
"categoryLabel": "Category",
|
||||
"fileLabel": "File",
|
||||
"fileHint": "PDF, images, text and Office files up to 5 MB.",
|
||||
"visibilityLabel": "Visibility",
|
||||
"statusLabel": "Status",
|
||||
"allowedMembersLabel": "Allowed members",
|
||||
"uploadAction": "Upload",
|
||||
"downloadAction": "Download",
|
||||
"editAction": "Settings",
|
||||
"archiveAction": "Archive",
|
||||
"restoreAction": "Restore",
|
||||
"savedToast": "Document saved.",
|
||||
"uploadedToast": "Document uploaded.",
|
||||
"archivedToast": "Document archived.",
|
||||
"restoredToast": "Document restored.",
|
||||
"deletedToast": "Document deleted.",
|
||||
"deleteConfirm": "Delete document \"{{name}}\"?",
|
||||
"fileRequired": "Select a file to upload.",
|
||||
"fileTooLarge": "File may be at most 5 MB.",
|
||||
"fileReadError": "File could not be read.",
|
||||
"statusActive": "Active",
|
||||
"statusArchived": "Archived",
|
||||
"visibility": {
|
||||
"family": "Όλη η οικογένεια",
|
||||
"restricted": "Επιλεγμένα μέλη",
|
||||
"private": "Μόνο εγώ"
|
||||
},
|
||||
"category": {
|
||||
"medical": "Ιατρικά",
|
||||
"school": "Σχολείο",
|
||||
"identity": "Ταυτότητα",
|
||||
"insurance": "Ασφάλιση",
|
||||
"finance": "Οικονομικά",
|
||||
"home": "Σπίτι",
|
||||
"vehicle": "Όχημα",
|
||||
"legal": "Νομικά",
|
||||
"travel": "Ταξίδια",
|
||||
"pets": "Κατοικίδια",
|
||||
"warranty": "Εγγύηση",
|
||||
"taxes": "Φόροι",
|
||||
"work": "Εργασία",
|
||||
"other": "Άλλο"
|
||||
},
|
||||
"dropzoneTitle": "Αφήστε το αρχείο εδώ ή κάντε κλικ για επιλογή",
|
||||
"dropzoneHint": "Σύρετε ένα αρχείο σε αυτήν την περιοχή ή χρησιμοποιήστε τον επιλογέα αρχείων.",
|
||||
"selectedFileLabel": "Επιλέχθηκε: {{name}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+70
-3
@@ -46,7 +46,8 @@
|
||||
"navigation": "Navigation",
|
||||
"quickActions": "Quick actions",
|
||||
"recipes": "Recipes",
|
||||
"more": "More"
|
||||
"more": "More",
|
||||
"documents": "Documents"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Overview",
|
||||
@@ -131,6 +132,7 @@
|
||||
"statusOpen": "Open",
|
||||
"statusInProgress": "In Progress",
|
||||
"statusDone": "Done",
|
||||
"statusArchived": "Archived",
|
||||
"categoryHousehold": "Household",
|
||||
"categorySchool": "School",
|
||||
"categoryShopping": "Shopping",
|
||||
@@ -152,6 +154,7 @@
|
||||
"markDone": "Mark {{title}} as done",
|
||||
"markOpen": "Mark {{title}} as open",
|
||||
"editButton": "Edit task",
|
||||
"archiveButton": "Archive task",
|
||||
"swipeOpen": "Reopen",
|
||||
"swipeDone": "Done",
|
||||
"swipeEdit": "Edit",
|
||||
@@ -162,11 +165,13 @@
|
||||
"savedToast": "Task saved.",
|
||||
"createdToast": "Task created.",
|
||||
"deletedToast": "Task deleted.",
|
||||
"archivedToast": "Task archived.",
|
||||
"loadError": "Task could not be loaded.",
|
||||
"subtaskPrompt": "Subtask:",
|
||||
"kanbanOpen": "Open",
|
||||
"kanbanInProgress": "In Progress",
|
||||
"kanbanDone": "Done",
|
||||
"kanbanArchived": "Archived",
|
||||
"kanbanMoveToInProgress": "Set to in progress",
|
||||
"kanbanMoveToDone": "Mark as done",
|
||||
"kanbanMoveToOpen": "Reopen",
|
||||
@@ -179,7 +184,8 @@
|
||||
"filterGroupPriority": "Priority",
|
||||
"filterGroupStatus": "Status",
|
||||
"swipedDoneToast": "Marked as done.",
|
||||
"swipedOpenToast": "Marked as open."
|
||||
"swipedOpenToast": "Marked as open.",
|
||||
"reminderNeedsDueDate": "Set a due date to enable task reminders."
|
||||
},
|
||||
"shopping": {
|
||||
"title": "Shopping",
|
||||
@@ -916,5 +922,66 @@
|
||||
"meals": "Plan meals for the week and link recipes.",
|
||||
"birthdays": "Add birthdays — you will receive a reminder in time.",
|
||||
"recipes": "Create recipes and link them to your meal planner."
|
||||
},
|
||||
"documents": {
|
||||
"title": "Documents",
|
||||
"addButton": "Add document",
|
||||
"searchPlaceholder": "Search documents...",
|
||||
"gridView": "Grid view",
|
||||
"listView": "List view",
|
||||
"viewToggle": "Document view",
|
||||
"allCategories": "All categories",
|
||||
"emptyTitle": "No documents yet",
|
||||
"emptyDescription": "Upload family documents and control who can see each file.",
|
||||
"newTitle": "New document",
|
||||
"editTitle": "Document settings",
|
||||
"nameLabel": "Name",
|
||||
"descriptionLabel": "Description",
|
||||
"categoryLabel": "Category",
|
||||
"fileLabel": "File",
|
||||
"fileHint": "PDF, images, text and Office files up to 5 MB.",
|
||||
"visibilityLabel": "Visibility",
|
||||
"statusLabel": "Status",
|
||||
"allowedMembersLabel": "Allowed members",
|
||||
"uploadAction": "Upload",
|
||||
"downloadAction": "Download",
|
||||
"editAction": "Settings",
|
||||
"archiveAction": "Archive",
|
||||
"restoreAction": "Restore",
|
||||
"savedToast": "Document saved.",
|
||||
"uploadedToast": "Document uploaded.",
|
||||
"archivedToast": "Document archived.",
|
||||
"restoredToast": "Document restored.",
|
||||
"deletedToast": "Document deleted.",
|
||||
"deleteConfirm": "Delete document \"{{name}}\"?",
|
||||
"fileRequired": "Select a file to upload.",
|
||||
"fileTooLarge": "File may be at most 5 MB.",
|
||||
"fileReadError": "File could not be read.",
|
||||
"statusActive": "Active",
|
||||
"statusArchived": "Archived",
|
||||
"visibility": {
|
||||
"family": "Entire family",
|
||||
"restricted": "Selected members",
|
||||
"private": "Only me"
|
||||
},
|
||||
"category": {
|
||||
"medical": "Medical",
|
||||
"school": "School",
|
||||
"identity": "Identity",
|
||||
"insurance": "Insurance",
|
||||
"finance": "Finance",
|
||||
"home": "Home",
|
||||
"vehicle": "Vehicle",
|
||||
"legal": "Legal",
|
||||
"travel": "Travel",
|
||||
"pets": "Pets",
|
||||
"warranty": "Warranty",
|
||||
"taxes": "Taxes",
|
||||
"work": "Work",
|
||||
"other": "Other"
|
||||
},
|
||||
"dropzoneTitle": "Drop file here or click to choose",
|
||||
"dropzoneHint": "Drag a file into this area, or use the file picker.",
|
||||
"selectedFileLabel": "Selected: {{name}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+64
-2
@@ -46,7 +46,8 @@
|
||||
"navigation": "Navegación",
|
||||
"quickActions": "Acciones rápidas",
|
||||
"recipes": "Recetas",
|
||||
"more": "Más"
|
||||
"more": "Más",
|
||||
"documents": "Documentos"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Inicio",
|
||||
@@ -897,5 +898,66 @@
|
||||
},
|
||||
"emptyHint": {
|
||||
"recipes": "Crea recetas y vincúlalas con tu planificador de comidas."
|
||||
},
|
||||
"documents": {
|
||||
"title": "Documentos",
|
||||
"addButton": "Agregar documento",
|
||||
"searchPlaceholder": "Buscar documentos...",
|
||||
"gridView": "Grid view",
|
||||
"listView": "List view",
|
||||
"viewToggle": "Document view",
|
||||
"allCategories": "Todas las categorías",
|
||||
"emptyTitle": "Aún no hay documentos",
|
||||
"emptyDescription": "Sube documentos familiares y controla quién puede ver cada archivo.",
|
||||
"newTitle": "Nuevo documento",
|
||||
"editTitle": "Configuración del documento",
|
||||
"nameLabel": "Name",
|
||||
"descriptionLabel": "Description",
|
||||
"categoryLabel": "Category",
|
||||
"fileLabel": "File",
|
||||
"fileHint": "PDF, images, text and Office files up to 5 MB.",
|
||||
"visibilityLabel": "Visibility",
|
||||
"statusLabel": "Status",
|
||||
"allowedMembersLabel": "Allowed members",
|
||||
"uploadAction": "Upload",
|
||||
"downloadAction": "Download",
|
||||
"editAction": "Settings",
|
||||
"archiveAction": "Archive",
|
||||
"restoreAction": "Restore",
|
||||
"savedToast": "Document saved.",
|
||||
"uploadedToast": "Document uploaded.",
|
||||
"archivedToast": "Document archived.",
|
||||
"restoredToast": "Document restored.",
|
||||
"deletedToast": "Document deleted.",
|
||||
"deleteConfirm": "Delete document \"{{name}}\"?",
|
||||
"fileRequired": "Select a file to upload.",
|
||||
"fileTooLarge": "File may be at most 5 MB.",
|
||||
"fileReadError": "File could not be read.",
|
||||
"statusActive": "Active",
|
||||
"statusArchived": "Archived",
|
||||
"visibility": {
|
||||
"family": "Toda la familia",
|
||||
"restricted": "Miembros seleccionados",
|
||||
"private": "Solo yo"
|
||||
},
|
||||
"category": {
|
||||
"medical": "Médico",
|
||||
"school": "Escuela",
|
||||
"identity": "Identidad",
|
||||
"insurance": "Seguro",
|
||||
"finance": "Finanzas",
|
||||
"home": "Hogar",
|
||||
"vehicle": "Vehículo",
|
||||
"legal": "Legal",
|
||||
"travel": "Viajes",
|
||||
"pets": "Mascotas",
|
||||
"warranty": "Garantía",
|
||||
"taxes": "Impuestos",
|
||||
"work": "Trabajo",
|
||||
"other": "Otros"
|
||||
},
|
||||
"dropzoneTitle": "Suelta el archivo aquí o haz clic para elegir",
|
||||
"dropzoneHint": "Arrastra un archivo a esta área o usa el selector de archivos.",
|
||||
"selectedFileLabel": "Seleccionado: {{name}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+64
-2
@@ -46,7 +46,8 @@
|
||||
"navigation": "Navigation",
|
||||
"quickActions": "Actions rapides",
|
||||
"recipes": "Recettes",
|
||||
"more": "Plus"
|
||||
"more": "Plus",
|
||||
"documents": "Documents"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Accueil",
|
||||
@@ -897,5 +898,66 @@
|
||||
},
|
||||
"emptyHint": {
|
||||
"recipes": "Créez des recettes et associez-les à votre planification des repas."
|
||||
},
|
||||
"documents": {
|
||||
"title": "Documents",
|
||||
"addButton": "Ajouter un document",
|
||||
"searchPlaceholder": "Rechercher des documents...",
|
||||
"gridView": "Grid view",
|
||||
"listView": "List view",
|
||||
"viewToggle": "Document view",
|
||||
"allCategories": "Toutes les catégories",
|
||||
"emptyTitle": "Aucun document pour le moment",
|
||||
"emptyDescription": "Ajoutez des documents familiaux et contrôlez qui peut voir chaque fichier.",
|
||||
"newTitle": "Nouveau document",
|
||||
"editTitle": "Paramètres du document",
|
||||
"nameLabel": "Name",
|
||||
"descriptionLabel": "Description",
|
||||
"categoryLabel": "Category",
|
||||
"fileLabel": "File",
|
||||
"fileHint": "PDF, images, text and Office files up to 5 MB.",
|
||||
"visibilityLabel": "Visibility",
|
||||
"statusLabel": "Status",
|
||||
"allowedMembersLabel": "Allowed members",
|
||||
"uploadAction": "Upload",
|
||||
"downloadAction": "Download",
|
||||
"editAction": "Settings",
|
||||
"archiveAction": "Archive",
|
||||
"restoreAction": "Restore",
|
||||
"savedToast": "Document saved.",
|
||||
"uploadedToast": "Document uploaded.",
|
||||
"archivedToast": "Document archived.",
|
||||
"restoredToast": "Document restored.",
|
||||
"deletedToast": "Document deleted.",
|
||||
"deleteConfirm": "Delete document \"{{name}}\"?",
|
||||
"fileRequired": "Select a file to upload.",
|
||||
"fileTooLarge": "File may be at most 5 MB.",
|
||||
"fileReadError": "File could not be read.",
|
||||
"statusActive": "Active",
|
||||
"statusArchived": "Archived",
|
||||
"visibility": {
|
||||
"family": "Toute la famille",
|
||||
"restricted": "Membres sélectionnés",
|
||||
"private": "Moi uniquement"
|
||||
},
|
||||
"category": {
|
||||
"medical": "Médical",
|
||||
"school": "École",
|
||||
"identity": "Identité",
|
||||
"insurance": "Assurance",
|
||||
"finance": "Finances",
|
||||
"home": "Maison",
|
||||
"vehicle": "Véhicule",
|
||||
"legal": "Juridique",
|
||||
"travel": "Voyage",
|
||||
"pets": "Animaux",
|
||||
"warranty": "Garantie",
|
||||
"taxes": "Impôts",
|
||||
"work": "Travail",
|
||||
"other": "Autre"
|
||||
},
|
||||
"dropzoneTitle": "Déposez le fichier ici ou cliquez pour choisir",
|
||||
"dropzoneHint": "Glissez un fichier dans cette zone ou utilisez le sélecteur.",
|
||||
"selectedFileLabel": "Sélectionné : {{name}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+64
-2
@@ -46,7 +46,8 @@
|
||||
"navigation": "नेविगेशन",
|
||||
"quickActions": "त्वरित क्रियाएं",
|
||||
"recipes": "रेसिपी",
|
||||
"more": "और"
|
||||
"more": "और",
|
||||
"documents": "दस्तावेज़"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "डैशबोर्ड",
|
||||
@@ -897,5 +898,66 @@
|
||||
},
|
||||
"emptyHint": {
|
||||
"recipes": "रेसिपी बनाएं और उन्हें अपने भोजन योजनाकार से जोड़ें।"
|
||||
},
|
||||
"documents": {
|
||||
"title": "दस्तावेज़",
|
||||
"addButton": "दस्तावेज़ जोड़ें",
|
||||
"searchPlaceholder": "दस्तावेज़ खोजें...",
|
||||
"gridView": "Grid view",
|
||||
"listView": "List view",
|
||||
"viewToggle": "Document view",
|
||||
"allCategories": "सभी श्रेणियाँ",
|
||||
"emptyTitle": "अभी कोई दस्तावेज़ नहीं",
|
||||
"emptyDescription": "परिवार के दस्तावेज़ अपलोड करें और तय करें कि हर फ़ाइल कौन देख सकता है।",
|
||||
"newTitle": "नया दस्तावेज़",
|
||||
"editTitle": "दस्तावेज़ सेटिंग्स",
|
||||
"nameLabel": "Name",
|
||||
"descriptionLabel": "Description",
|
||||
"categoryLabel": "Category",
|
||||
"fileLabel": "File",
|
||||
"fileHint": "PDF, images, text and Office files up to 5 MB.",
|
||||
"visibilityLabel": "Visibility",
|
||||
"statusLabel": "Status",
|
||||
"allowedMembersLabel": "Allowed members",
|
||||
"uploadAction": "Upload",
|
||||
"downloadAction": "Download",
|
||||
"editAction": "Settings",
|
||||
"archiveAction": "Archive",
|
||||
"restoreAction": "Restore",
|
||||
"savedToast": "Document saved.",
|
||||
"uploadedToast": "Document uploaded.",
|
||||
"archivedToast": "Document archived.",
|
||||
"restoredToast": "Document restored.",
|
||||
"deletedToast": "Document deleted.",
|
||||
"deleteConfirm": "Delete document \"{{name}}\"?",
|
||||
"fileRequired": "Select a file to upload.",
|
||||
"fileTooLarge": "File may be at most 5 MB.",
|
||||
"fileReadError": "File could not be read.",
|
||||
"statusActive": "Active",
|
||||
"statusArchived": "Archived",
|
||||
"visibility": {
|
||||
"family": "पूरा परिवार",
|
||||
"restricted": "चुने हुए सदस्य",
|
||||
"private": "केवल मैं"
|
||||
},
|
||||
"category": {
|
||||
"medical": "चिकित्सा",
|
||||
"school": "स्कूल",
|
||||
"identity": "पहचान",
|
||||
"insurance": "बीमा",
|
||||
"finance": "वित्त",
|
||||
"home": "घर",
|
||||
"vehicle": "वाहन",
|
||||
"legal": "कानूनी",
|
||||
"travel": "यात्रा",
|
||||
"pets": "पालतू",
|
||||
"warranty": "वारंटी",
|
||||
"taxes": "कर",
|
||||
"work": "काम",
|
||||
"other": "अन्य"
|
||||
},
|
||||
"dropzoneTitle": "फ़ाइल यहाँ छोड़ें या चुनने के लिए क्लिक करें",
|
||||
"dropzoneHint": "फ़ाइल को इस क्षेत्र में खींचें या फ़ाइल पिकर का उपयोग करें।",
|
||||
"selectedFileLabel": "चयनित: {{name}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+64
-2
@@ -46,7 +46,8 @@
|
||||
"navigation": "Navigazione",
|
||||
"quickActions": "Azioni rapide",
|
||||
"recipes": "Ricette",
|
||||
"more": "Altro"
|
||||
"more": "Altro",
|
||||
"documents": "Documenti"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Panoramica",
|
||||
@@ -897,5 +898,66 @@
|
||||
},
|
||||
"emptyHint": {
|
||||
"recipes": "Crea ricette e collegale al tuo piano pasti."
|
||||
},
|
||||
"documents": {
|
||||
"title": "Documenti",
|
||||
"addButton": "Aggiungi documento",
|
||||
"searchPlaceholder": "Cerca documenti...",
|
||||
"gridView": "Grid view",
|
||||
"listView": "List view",
|
||||
"viewToggle": "Document view",
|
||||
"allCategories": "Tutte le categorie",
|
||||
"emptyTitle": "Nessun documento",
|
||||
"emptyDescription": "Carica documenti di famiglia e controlla chi può vedere ogni file.",
|
||||
"newTitle": "Nuovo documento",
|
||||
"editTitle": "Impostazioni documento",
|
||||
"nameLabel": "Name",
|
||||
"descriptionLabel": "Description",
|
||||
"categoryLabel": "Category",
|
||||
"fileLabel": "File",
|
||||
"fileHint": "PDF, images, text and Office files up to 5 MB.",
|
||||
"visibilityLabel": "Visibility",
|
||||
"statusLabel": "Status",
|
||||
"allowedMembersLabel": "Allowed members",
|
||||
"uploadAction": "Upload",
|
||||
"downloadAction": "Download",
|
||||
"editAction": "Settings",
|
||||
"archiveAction": "Archive",
|
||||
"restoreAction": "Restore",
|
||||
"savedToast": "Document saved.",
|
||||
"uploadedToast": "Document uploaded.",
|
||||
"archivedToast": "Document archived.",
|
||||
"restoredToast": "Document restored.",
|
||||
"deletedToast": "Document deleted.",
|
||||
"deleteConfirm": "Delete document \"{{name}}\"?",
|
||||
"fileRequired": "Select a file to upload.",
|
||||
"fileTooLarge": "File may be at most 5 MB.",
|
||||
"fileReadError": "File could not be read.",
|
||||
"statusActive": "Active",
|
||||
"statusArchived": "Archived",
|
||||
"visibility": {
|
||||
"family": "Tutta la famiglia",
|
||||
"restricted": "Membri selezionati",
|
||||
"private": "Solo io"
|
||||
},
|
||||
"category": {
|
||||
"medical": "Medico",
|
||||
"school": "Scuola",
|
||||
"identity": "Identità",
|
||||
"insurance": "Assicurazione",
|
||||
"finance": "Finanze",
|
||||
"home": "Casa",
|
||||
"vehicle": "Veicolo",
|
||||
"legal": "Legale",
|
||||
"travel": "Viaggi",
|
||||
"pets": "Animali",
|
||||
"warranty": "Garanzia",
|
||||
"taxes": "Tasse",
|
||||
"work": "Lavoro",
|
||||
"other": "Altro"
|
||||
},
|
||||
"dropzoneTitle": "Rilascia il file qui o fai clic per scegliere",
|
||||
"dropzoneHint": "Trascina un file in quest’area oppure usa il selettore.",
|
||||
"selectedFileLabel": "Selezionato: {{name}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+64
-2
@@ -46,7 +46,8 @@
|
||||
"navigation": "ナビゲーション",
|
||||
"quickActions": "クイックアクション",
|
||||
"recipes": "レシピ",
|
||||
"more": "もっと見る"
|
||||
"more": "もっと見る",
|
||||
"documents": "書類"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "ダッシュボード",
|
||||
@@ -897,5 +898,66 @@
|
||||
},
|
||||
"emptyHint": {
|
||||
"recipes": "レシピを作成して、食事プランに関連付けましょう。"
|
||||
},
|
||||
"documents": {
|
||||
"title": "書類",
|
||||
"addButton": "書類を追加",
|
||||
"searchPlaceholder": "書類を検索...",
|
||||
"gridView": "Grid view",
|
||||
"listView": "List view",
|
||||
"viewToggle": "Document view",
|
||||
"allCategories": "すべてのカテゴリ",
|
||||
"emptyTitle": "書類はまだありません",
|
||||
"emptyDescription": "家族の書類をアップロードし、各ファイルを見られるメンバーを管理できます。",
|
||||
"newTitle": "新しい書類",
|
||||
"editTitle": "書類設定",
|
||||
"nameLabel": "Name",
|
||||
"descriptionLabel": "Description",
|
||||
"categoryLabel": "Category",
|
||||
"fileLabel": "File",
|
||||
"fileHint": "PDF, images, text and Office files up to 5 MB.",
|
||||
"visibilityLabel": "Visibility",
|
||||
"statusLabel": "Status",
|
||||
"allowedMembersLabel": "Allowed members",
|
||||
"uploadAction": "Upload",
|
||||
"downloadAction": "Download",
|
||||
"editAction": "Settings",
|
||||
"archiveAction": "Archive",
|
||||
"restoreAction": "Restore",
|
||||
"savedToast": "Document saved.",
|
||||
"uploadedToast": "Document uploaded.",
|
||||
"archivedToast": "Document archived.",
|
||||
"restoredToast": "Document restored.",
|
||||
"deletedToast": "Document deleted.",
|
||||
"deleteConfirm": "Delete document \"{{name}}\"?",
|
||||
"fileRequired": "Select a file to upload.",
|
||||
"fileTooLarge": "File may be at most 5 MB.",
|
||||
"fileReadError": "File could not be read.",
|
||||
"statusActive": "Active",
|
||||
"statusArchived": "Archived",
|
||||
"visibility": {
|
||||
"family": "家族全員",
|
||||
"restricted": "選択したメンバー",
|
||||
"private": "自分のみ"
|
||||
},
|
||||
"category": {
|
||||
"medical": "医療",
|
||||
"school": "学校",
|
||||
"identity": "本人確認",
|
||||
"insurance": "保険",
|
||||
"finance": "金融",
|
||||
"home": "家",
|
||||
"vehicle": "車両",
|
||||
"legal": "法務",
|
||||
"travel": "旅行",
|
||||
"pets": "ペット",
|
||||
"warranty": "保証",
|
||||
"taxes": "税金",
|
||||
"work": "仕事",
|
||||
"other": "その他"
|
||||
},
|
||||
"dropzoneTitle": "ここにファイルをドロップ、またはクリックして選択",
|
||||
"dropzoneHint": "この領域にファイルをドラッグするか、ファイル選択を使用します。",
|
||||
"selectedFileLabel": "選択済み: {{name}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+70
-3
@@ -46,7 +46,8 @@
|
||||
"navigation": "Navegação",
|
||||
"quickActions": "Ações rápidas",
|
||||
"recipes": "Receitas",
|
||||
"more": "Mais"
|
||||
"more": "Mais",
|
||||
"documents": "Documentos"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Painel",
|
||||
@@ -131,6 +132,7 @@
|
||||
"statusOpen": "Aberto",
|
||||
"statusInProgress": "Em andamento",
|
||||
"statusDone": "Concluído",
|
||||
"statusArchived": "Arquivado",
|
||||
"categoryHousehold": "Casa",
|
||||
"categorySchool": "Escola",
|
||||
"categoryShopping": "Compras",
|
||||
@@ -152,6 +154,7 @@
|
||||
"markDone": "Marcar {{title}} como concluído",
|
||||
"markOpen": "Marcar {{title}} como pendente",
|
||||
"editButton": "Editar tarefa",
|
||||
"archiveButton": "Arquivar tarefa",
|
||||
"swipeOpen": "Abrir",
|
||||
"swipeDone": "Concluído",
|
||||
"swipeEdit": "Editar",
|
||||
@@ -162,11 +165,13 @@
|
||||
"savedToast": "Tarefa salva.",
|
||||
"createdToast": "Tarefa criada.",
|
||||
"deletedToast": "Tarefa excluída.",
|
||||
"archivedToast": "Tarefa arquivada.",
|
||||
"loadError": "Falha ao carregar a tarefa.",
|
||||
"subtaskPrompt": "Subtarefa:",
|
||||
"kanbanOpen": "Aberto",
|
||||
"kanbanInProgress": "Em andamento",
|
||||
"kanbanDone": "Concluído",
|
||||
"kanbanArchived": "Arquivado",
|
||||
"kanbanMoveToInProgress": "Mover para em andamento",
|
||||
"kanbanMoveToDone": "Marcar como concluído",
|
||||
"kanbanMoveToOpen": "Reabrir",
|
||||
@@ -179,7 +184,8 @@
|
||||
"filterGroupPriority": "Prioridade",
|
||||
"filterGroupStatus": "Estado",
|
||||
"swipedDoneToast": "Marcado como concluído.",
|
||||
"swipedOpenToast": "Marcado como aberto."
|
||||
"swipedOpenToast": "Marcado como aberto.",
|
||||
"reminderNeedsDueDate": "Defina uma data de vencimento para habilitar lembretes da tarefa."
|
||||
},
|
||||
"shopping": {
|
||||
"title": "Compras",
|
||||
@@ -898,5 +904,66 @@
|
||||
},
|
||||
"emptyHint": {
|
||||
"recipes": "Crie receitas e vincule-as ao seu planejador de refeições."
|
||||
},
|
||||
"documents": {
|
||||
"title": "Documentos",
|
||||
"addButton": "Adicionar documento",
|
||||
"searchPlaceholder": "Buscar documentos...",
|
||||
"gridView": "Visualizacao em grade",
|
||||
"listView": "Visualizacao em lista",
|
||||
"viewToggle": "Visualizacao de documentos",
|
||||
"allCategories": "Todas as categorias",
|
||||
"emptyTitle": "Nenhum documento ainda",
|
||||
"emptyDescription": "Envie documentos da familia e controle quem pode ver cada arquivo.",
|
||||
"newTitle": "Novo documento",
|
||||
"editTitle": "Configuracoes do documento",
|
||||
"nameLabel": "Nome",
|
||||
"descriptionLabel": "Descricao",
|
||||
"categoryLabel": "Categoria",
|
||||
"fileLabel": "Arquivo",
|
||||
"fileHint": "PDF, imagens, texto e arquivos Office ate 5 MB.",
|
||||
"visibilityLabel": "Visibilidade",
|
||||
"statusLabel": "Status",
|
||||
"allowedMembersLabel": "Membros permitidos",
|
||||
"uploadAction": "Enviar",
|
||||
"downloadAction": "Baixar",
|
||||
"editAction": "Configuracoes",
|
||||
"archiveAction": "Arquivar",
|
||||
"restoreAction": "Restaurar",
|
||||
"savedToast": "Documento salvo.",
|
||||
"uploadedToast": "Documento enviado.",
|
||||
"archivedToast": "Documento arquivado.",
|
||||
"restoredToast": "Documento restaurado.",
|
||||
"deletedToast": "Documento excluido.",
|
||||
"deleteConfirm": "Excluir documento \"{{name}}\"?",
|
||||
"fileRequired": "Selecione um arquivo para enviar.",
|
||||
"fileTooLarge": "O arquivo pode ter no maximo 5 MB.",
|
||||
"fileReadError": "Nao foi possivel ler o arquivo.",
|
||||
"statusActive": "Ativo",
|
||||
"statusArchived": "Arquivado",
|
||||
"visibility": {
|
||||
"family": "Familia inteira",
|
||||
"restricted": "Membros selecionados",
|
||||
"private": "Somente eu"
|
||||
},
|
||||
"category": {
|
||||
"medical": "Medico",
|
||||
"school": "Escola",
|
||||
"identity": "Identidade",
|
||||
"insurance": "Seguro",
|
||||
"finance": "Financeiro",
|
||||
"home": "Casa",
|
||||
"vehicle": "Veiculo",
|
||||
"legal": "Juridico",
|
||||
"travel": "Viagem",
|
||||
"pets": "Pets",
|
||||
"warranty": "Garantia",
|
||||
"taxes": "Impostos",
|
||||
"work": "Trabalho",
|
||||
"other": "Outros"
|
||||
},
|
||||
"dropzoneTitle": "Solte o arquivo aqui ou clique para escolher",
|
||||
"dropzoneHint": "Arraste um arquivo para esta area, ou use o seletor de arquivos.",
|
||||
"selectedFileLabel": "Selecionado: {{name}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+64
-2
@@ -46,7 +46,8 @@
|
||||
"navigation": "Навигация",
|
||||
"quickActions": "Быстрые действия",
|
||||
"recipes": "Рецепты",
|
||||
"more": "Ещё"
|
||||
"more": "Ещё",
|
||||
"documents": "Документы"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Обзор",
|
||||
@@ -897,5 +898,66 @@
|
||||
},
|
||||
"emptyHint": {
|
||||
"recipes": "Создавайте рецепты и связывайте их с вашим планом питания."
|
||||
},
|
||||
"documents": {
|
||||
"title": "Документы",
|
||||
"addButton": "Добавить документ",
|
||||
"searchPlaceholder": "Поиск документов...",
|
||||
"gridView": "Grid view",
|
||||
"listView": "List view",
|
||||
"viewToggle": "Document view",
|
||||
"allCategories": "Все категории",
|
||||
"emptyTitle": "Документов пока нет",
|
||||
"emptyDescription": "Загружайте семейные документы и управляйте доступом к каждому файлу.",
|
||||
"newTitle": "Новый документ",
|
||||
"editTitle": "Настройки документа",
|
||||
"nameLabel": "Name",
|
||||
"descriptionLabel": "Description",
|
||||
"categoryLabel": "Category",
|
||||
"fileLabel": "File",
|
||||
"fileHint": "PDF, images, text and Office files up to 5 MB.",
|
||||
"visibilityLabel": "Visibility",
|
||||
"statusLabel": "Status",
|
||||
"allowedMembersLabel": "Allowed members",
|
||||
"uploadAction": "Upload",
|
||||
"downloadAction": "Download",
|
||||
"editAction": "Settings",
|
||||
"archiveAction": "Archive",
|
||||
"restoreAction": "Restore",
|
||||
"savedToast": "Document saved.",
|
||||
"uploadedToast": "Document uploaded.",
|
||||
"archivedToast": "Document archived.",
|
||||
"restoredToast": "Document restored.",
|
||||
"deletedToast": "Document deleted.",
|
||||
"deleteConfirm": "Delete document \"{{name}}\"?",
|
||||
"fileRequired": "Select a file to upload.",
|
||||
"fileTooLarge": "File may be at most 5 MB.",
|
||||
"fileReadError": "File could not be read.",
|
||||
"statusActive": "Active",
|
||||
"statusArchived": "Archived",
|
||||
"visibility": {
|
||||
"family": "Вся семья",
|
||||
"restricted": "Выбранные участники",
|
||||
"private": "Только я"
|
||||
},
|
||||
"category": {
|
||||
"medical": "Медицина",
|
||||
"school": "Школа",
|
||||
"identity": "Удостоверения",
|
||||
"insurance": "Страхование",
|
||||
"finance": "Финансы",
|
||||
"home": "Дом",
|
||||
"vehicle": "Автомобиль",
|
||||
"legal": "Юридическое",
|
||||
"travel": "Путешествия",
|
||||
"pets": "Питомцы",
|
||||
"warranty": "Гарантия",
|
||||
"taxes": "Налоги",
|
||||
"work": "Работа",
|
||||
"other": "Другое"
|
||||
},
|
||||
"dropzoneTitle": "Перетащите файл сюда или нажмите для выбора",
|
||||
"dropzoneHint": "Перетащите файл в эту область или используйте выбор файла.",
|
||||
"selectedFileLabel": "Выбрано: {{name}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+64
-2
@@ -46,7 +46,8 @@
|
||||
"navigation": "Navigering",
|
||||
"quickActions": "Snabba åtgärder",
|
||||
"recipes": "Recept",
|
||||
"more": "Mer"
|
||||
"more": "Mer",
|
||||
"documents": "Dokument"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Översikt",
|
||||
@@ -897,5 +898,66 @@
|
||||
},
|
||||
"emptyHint": {
|
||||
"recipes": "Skapa recept och koppla dem till din måltidsplanering."
|
||||
},
|
||||
"documents": {
|
||||
"title": "Dokument",
|
||||
"addButton": "Lägg till dokument",
|
||||
"searchPlaceholder": "Sök dokument...",
|
||||
"gridView": "Grid view",
|
||||
"listView": "List view",
|
||||
"viewToggle": "Document view",
|
||||
"allCategories": "Alla kategorier",
|
||||
"emptyTitle": "Inga dokument ännu",
|
||||
"emptyDescription": "Ladda upp familjedokument och styr vem som kan se varje fil.",
|
||||
"newTitle": "Nytt dokument",
|
||||
"editTitle": "Dokumentinställningar",
|
||||
"nameLabel": "Name",
|
||||
"descriptionLabel": "Description",
|
||||
"categoryLabel": "Category",
|
||||
"fileLabel": "File",
|
||||
"fileHint": "PDF, images, text and Office files up to 5 MB.",
|
||||
"visibilityLabel": "Visibility",
|
||||
"statusLabel": "Status",
|
||||
"allowedMembersLabel": "Allowed members",
|
||||
"uploadAction": "Upload",
|
||||
"downloadAction": "Download",
|
||||
"editAction": "Settings",
|
||||
"archiveAction": "Archive",
|
||||
"restoreAction": "Restore",
|
||||
"savedToast": "Document saved.",
|
||||
"uploadedToast": "Document uploaded.",
|
||||
"archivedToast": "Document archived.",
|
||||
"restoredToast": "Document restored.",
|
||||
"deletedToast": "Document deleted.",
|
||||
"deleteConfirm": "Delete document \"{{name}}\"?",
|
||||
"fileRequired": "Select a file to upload.",
|
||||
"fileTooLarge": "File may be at most 5 MB.",
|
||||
"fileReadError": "File could not be read.",
|
||||
"statusActive": "Active",
|
||||
"statusArchived": "Archived",
|
||||
"visibility": {
|
||||
"family": "Hela familjen",
|
||||
"restricted": "Valda medlemmar",
|
||||
"private": "Endast jag"
|
||||
},
|
||||
"category": {
|
||||
"medical": "Medicinskt",
|
||||
"school": "Skola",
|
||||
"identity": "Identitet",
|
||||
"insurance": "Försäkring",
|
||||
"finance": "Ekonomi",
|
||||
"home": "Hem",
|
||||
"vehicle": "Fordon",
|
||||
"legal": "Juridiskt",
|
||||
"travel": "Resor",
|
||||
"pets": "Husdjur",
|
||||
"warranty": "Garanti",
|
||||
"taxes": "Skatter",
|
||||
"work": "Arbete",
|
||||
"other": "Övrigt"
|
||||
},
|
||||
"dropzoneTitle": "Släpp filen här eller klicka för att välja",
|
||||
"dropzoneHint": "Dra en fil till området eller använd filväljaren.",
|
||||
"selectedFileLabel": "Vald: {{name}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+64
-2
@@ -46,7 +46,8 @@
|
||||
"navigation": "Gezinme",
|
||||
"quickActions": "Hızlı işlemler",
|
||||
"recipes": "Tarifler",
|
||||
"more": "Daha Fazla"
|
||||
"more": "Daha Fazla",
|
||||
"documents": "Belgeler"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Genel Bakış",
|
||||
@@ -897,5 +898,66 @@
|
||||
},
|
||||
"emptyHint": {
|
||||
"recipes": "Tarifler oluşturun ve yemek planlayıcınıza bağlayın."
|
||||
},
|
||||
"documents": {
|
||||
"title": "Belgeler",
|
||||
"addButton": "Belge ekle",
|
||||
"searchPlaceholder": "Belgelerde ara...",
|
||||
"gridView": "Grid view",
|
||||
"listView": "List view",
|
||||
"viewToggle": "Document view",
|
||||
"allCategories": "Tüm kategoriler",
|
||||
"emptyTitle": "Henüz belge yok",
|
||||
"emptyDescription": "Aile belgelerini yükleyin ve her dosyayı kimlerin görebileceğini yönetin.",
|
||||
"newTitle": "Yeni belge",
|
||||
"editTitle": "Belge ayarları",
|
||||
"nameLabel": "Name",
|
||||
"descriptionLabel": "Description",
|
||||
"categoryLabel": "Category",
|
||||
"fileLabel": "File",
|
||||
"fileHint": "PDF, images, text and Office files up to 5 MB.",
|
||||
"visibilityLabel": "Visibility",
|
||||
"statusLabel": "Status",
|
||||
"allowedMembersLabel": "Allowed members",
|
||||
"uploadAction": "Upload",
|
||||
"downloadAction": "Download",
|
||||
"editAction": "Settings",
|
||||
"archiveAction": "Archive",
|
||||
"restoreAction": "Restore",
|
||||
"savedToast": "Document saved.",
|
||||
"uploadedToast": "Document uploaded.",
|
||||
"archivedToast": "Document archived.",
|
||||
"restoredToast": "Document restored.",
|
||||
"deletedToast": "Document deleted.",
|
||||
"deleteConfirm": "Delete document \"{{name}}\"?",
|
||||
"fileRequired": "Select a file to upload.",
|
||||
"fileTooLarge": "File may be at most 5 MB.",
|
||||
"fileReadError": "File could not be read.",
|
||||
"statusActive": "Active",
|
||||
"statusArchived": "Archived",
|
||||
"visibility": {
|
||||
"family": "Tüm aile",
|
||||
"restricted": "Seçili üyeler",
|
||||
"private": "Sadece ben"
|
||||
},
|
||||
"category": {
|
||||
"medical": "Tıbbi",
|
||||
"school": "Okul",
|
||||
"identity": "Kimlik",
|
||||
"insurance": "Sigorta",
|
||||
"finance": "Finans",
|
||||
"home": "Ev",
|
||||
"vehicle": "Araç",
|
||||
"legal": "Hukuki",
|
||||
"travel": "Seyahat",
|
||||
"pets": "Evcil hayvanlar",
|
||||
"warranty": "Garanti",
|
||||
"taxes": "Vergiler",
|
||||
"work": "İş",
|
||||
"other": "Diğer"
|
||||
},
|
||||
"dropzoneTitle": "Dosyayı buraya bırakın veya seçmek için tıklayın",
|
||||
"dropzoneHint": "Bir dosyayı bu alana sürükleyin veya dosya seçiciyi kullanın.",
|
||||
"selectedFileLabel": "Seçildi: {{name}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+64
-2
@@ -46,7 +46,8 @@
|
||||
"navigation": "Навігація",
|
||||
"quickActions": "Швидкі дії",
|
||||
"recipes": "Рецепти",
|
||||
"more": "Більше"
|
||||
"more": "Більше",
|
||||
"documents": "Документи"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Огляд",
|
||||
@@ -905,5 +906,66 @@
|
||||
"meals": "Плануйте харчування на тиждень і пов'язуйте рецепти.",
|
||||
"birthdays": "Додайте дні народження — ви отримаєте нагадування завчасно.",
|
||||
"recipes": "Створюйте рецепти та пов'язуйте їх із планувальником харчування."
|
||||
},
|
||||
"documents": {
|
||||
"title": "Документи",
|
||||
"addButton": "Додати документ",
|
||||
"searchPlaceholder": "Пошук документів...",
|
||||
"gridView": "Grid view",
|
||||
"listView": "List view",
|
||||
"viewToggle": "Document view",
|
||||
"allCategories": "Усі категорії",
|
||||
"emptyTitle": "Документів ще немає",
|
||||
"emptyDescription": "Завантажуйте сімейні документи та керуйте доступом до кожного файлу.",
|
||||
"newTitle": "Новий документ",
|
||||
"editTitle": "Налаштування документа",
|
||||
"nameLabel": "Name",
|
||||
"descriptionLabel": "Description",
|
||||
"categoryLabel": "Category",
|
||||
"fileLabel": "File",
|
||||
"fileHint": "PDF, images, text and Office files up to 5 MB.",
|
||||
"visibilityLabel": "Visibility",
|
||||
"statusLabel": "Status",
|
||||
"allowedMembersLabel": "Allowed members",
|
||||
"uploadAction": "Upload",
|
||||
"downloadAction": "Download",
|
||||
"editAction": "Settings",
|
||||
"archiveAction": "Archive",
|
||||
"restoreAction": "Restore",
|
||||
"savedToast": "Document saved.",
|
||||
"uploadedToast": "Document uploaded.",
|
||||
"archivedToast": "Document archived.",
|
||||
"restoredToast": "Document restored.",
|
||||
"deletedToast": "Document deleted.",
|
||||
"deleteConfirm": "Delete document \"{{name}}\"?",
|
||||
"fileRequired": "Select a file to upload.",
|
||||
"fileTooLarge": "File may be at most 5 MB.",
|
||||
"fileReadError": "File could not be read.",
|
||||
"statusActive": "Active",
|
||||
"statusArchived": "Archived",
|
||||
"visibility": {
|
||||
"family": "Уся сім’я",
|
||||
"restricted": "Вибрані учасники",
|
||||
"private": "Лише я"
|
||||
},
|
||||
"category": {
|
||||
"medical": "Медицина",
|
||||
"school": "Школа",
|
||||
"identity": "Посвідчення",
|
||||
"insurance": "Страхування",
|
||||
"finance": "Фінанси",
|
||||
"home": "Дім",
|
||||
"vehicle": "Авто",
|
||||
"legal": "Юридичне",
|
||||
"travel": "Подорожі",
|
||||
"pets": "Тварини",
|
||||
"warranty": "Гарантія",
|
||||
"taxes": "Податки",
|
||||
"work": "Робота",
|
||||
"other": "Інше"
|
||||
},
|
||||
"dropzoneTitle": "Перетягніть файл сюди або натисніть для вибору",
|
||||
"dropzoneHint": "Перетягніть файл у цю область або скористайтеся вибором файлу.",
|
||||
"selectedFileLabel": "Вибрано: {{name}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+64
-2
@@ -46,7 +46,8 @@
|
||||
"navigation": "导航",
|
||||
"quickActions": "快捷操作",
|
||||
"recipes": "食谱",
|
||||
"more": "更多"
|
||||
"more": "更多",
|
||||
"documents": "文档"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "概览",
|
||||
@@ -897,5 +898,66 @@
|
||||
},
|
||||
"emptyHint": {
|
||||
"recipes": "创建食谱并将其关联到你的膳食计划。"
|
||||
},
|
||||
"documents": {
|
||||
"title": "文档",
|
||||
"addButton": "添加文档",
|
||||
"searchPlaceholder": "搜索文档...",
|
||||
"gridView": "Grid view",
|
||||
"listView": "List view",
|
||||
"viewToggle": "Document view",
|
||||
"allCategories": "所有类别",
|
||||
"emptyTitle": "还没有文档",
|
||||
"emptyDescription": "上传家庭文档并控制每个文件的可见成员。",
|
||||
"newTitle": "新文档",
|
||||
"editTitle": "文档设置",
|
||||
"nameLabel": "Name",
|
||||
"descriptionLabel": "Description",
|
||||
"categoryLabel": "Category",
|
||||
"fileLabel": "File",
|
||||
"fileHint": "PDF, images, text and Office files up to 5 MB.",
|
||||
"visibilityLabel": "Visibility",
|
||||
"statusLabel": "Status",
|
||||
"allowedMembersLabel": "Allowed members",
|
||||
"uploadAction": "Upload",
|
||||
"downloadAction": "Download",
|
||||
"editAction": "Settings",
|
||||
"archiveAction": "Archive",
|
||||
"restoreAction": "Restore",
|
||||
"savedToast": "Document saved.",
|
||||
"uploadedToast": "Document uploaded.",
|
||||
"archivedToast": "Document archived.",
|
||||
"restoredToast": "Document restored.",
|
||||
"deletedToast": "Document deleted.",
|
||||
"deleteConfirm": "Delete document \"{{name}}\"?",
|
||||
"fileRequired": "Select a file to upload.",
|
||||
"fileTooLarge": "File may be at most 5 MB.",
|
||||
"fileReadError": "File could not be read.",
|
||||
"statusActive": "Active",
|
||||
"statusArchived": "Archived",
|
||||
"visibility": {
|
||||
"family": "整个家庭",
|
||||
"restricted": "选定成员",
|
||||
"private": "仅我"
|
||||
},
|
||||
"category": {
|
||||
"medical": "医疗",
|
||||
"school": "学校",
|
||||
"identity": "身份",
|
||||
"insurance": "保险",
|
||||
"finance": "财务",
|
||||
"home": "家庭",
|
||||
"vehicle": "车辆",
|
||||
"legal": "法律",
|
||||
"travel": "旅行",
|
||||
"pets": "宠物",
|
||||
"warranty": "保修",
|
||||
"taxes": "税务",
|
||||
"work": "工作",
|
||||
"other": "其他"
|
||||
},
|
||||
"dropzoneTitle": "将文件拖到此处或点击选择",
|
||||
"dropzoneHint": "将文件拖入此区域,或使用文件选择器。",
|
||||
"selectedFileLabel": "已选择:{{name}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -30,6 +30,7 @@ const STATUSES = () => [
|
||||
{ value: 'open', label: t('tasks.statusOpen') },
|
||||
{ value: 'in_progress', label: t('tasks.statusInProgress') },
|
||||
{ value: 'done', label: t('tasks.statusDone') },
|
||||
{ value: 'archived', label: t('tasks.statusArchived') },
|
||||
];
|
||||
|
||||
const CATEGORIES = [
|
||||
@@ -208,6 +209,11 @@ function renderTaskCard(task, opts = {}) {
|
||||
aria-label="${t('tasks.editButton')}">
|
||||
<i data-lucide="pencil" class="icon-base" aria-hidden="true"></i>
|
||||
</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>
|
||||
|
||||
${progress !== null ? `
|
||||
@@ -376,7 +382,7 @@ function renderModalContent({ task = null, users = [], reminder = null } = {}) {
|
||||
|
||||
${renderRRuleFields('task', task?.recurrence_rule)}
|
||||
|
||||
${renderReminderSection(reminder)}
|
||||
${renderReminderSection(task, reminder)}
|
||||
|
||||
<div id="task-form-error" class="login-error" hidden></div>
|
||||
|
||||
@@ -446,10 +452,36 @@ async function loadReminderForTask(taskId) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderReminderSection(reminder = null) {
|
||||
const hasReminder = !!reminder;
|
||||
const remindDate = hasReminder ? reminder.remind_at.slice(0, 10) : '';
|
||||
const remindTime = hasReminder ? reminder.remind_at.slice(11, 16) : '';
|
||||
function parseOffsetMsFromReminder(task, reminder) {
|
||||
if (!task?.due_date || !reminder?.remind_at) return null;
|
||||
const due = task.due_time ? new Date(`${task.due_date}T${task.due_time}`) : new Date(`${task.due_date}T23:59:59`);
|
||||
const remind = new Date(reminder.remind_at);
|
||||
if (Number.isNaN(due.getTime()) || Number.isNaN(remind.getTime())) return null;
|
||||
return due.getTime() - remind.getTime();
|
||||
}
|
||||
|
||||
function resolveReminderPreset(task, reminder) {
|
||||
const offset = parseOffsetMsFromReminder(task, reminder);
|
||||
if (offset === null) return { preset: 'offset_15m', amount: '15', unit: 'minutes' };
|
||||
const map = new Map([
|
||||
[0, 'offset_at_time'],
|
||||
[15 * 60 * 1000, 'offset_15m'],
|
||||
[60 * 60 * 1000, 'offset_1h'],
|
||||
[24 * 60 * 60 * 1000, 'offset_1d'],
|
||||
[2 * 24 * 60 * 60 * 1000, 'offset_2d'],
|
||||
[7 * 24 * 60 * 60 * 1000, 'offset_1w'],
|
||||
[14 * 24 * 60 * 60 * 1000, 'offset_2w'],
|
||||
]);
|
||||
if (map.has(offset)) return { preset: map.get(offset), amount: '1', unit: 'days' };
|
||||
const minutes = Math.round(offset / 60000);
|
||||
if (minutes > 0) return { preset: 'offset_custom', amount: String(minutes), unit: 'minutes' };
|
||||
return { preset: 'offset_at_time', amount: '1', unit: 'days' };
|
||||
}
|
||||
|
||||
function renderReminderSection(task = null, reminder = null) {
|
||||
const hasReminder = !!reminder;
|
||||
const resolved = resolveReminderPreset(task, reminder);
|
||||
const showCustom = hasReminder && resolved.preset === 'offset_custom';
|
||||
|
||||
return `
|
||||
<div class="reminder-section">
|
||||
@@ -462,12 +494,33 @@ function renderReminderSection(reminder = null) {
|
||||
</div>
|
||||
<div id="reminder-fields" class="reminder-fields" ${hasReminder ? '' : 'style="display:none"'}>
|
||||
<div class="form-group" style="margin:0">
|
||||
<label class="label" for="reminder-date">${t('reminders.dateLabel')}</label>
|
||||
<input class="input js-date-input" type="text" id="reminder-date" value="${formatDateInput(remindDate)}" placeholder="${dateInputPlaceholder()}" inputmode="numeric">
|
||||
<label class="label" for="reminder-offset">${t('reminders.offsetLabel')}</label>
|
||||
<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 class="form-group" style="margin:0">
|
||||
<label class="label" for="reminder-time">${t('reminders.timeLabel')}</label>
|
||||
<input class="input" type="time" id="reminder-time" value="${remindTime || '08:00'}">
|
||||
<div class="modal-grid modal-grid--2" id="reminder-custom-fields" style="${showCustom ? '' : 'display:none'};margin-top:var(--space-3)">
|
||||
<div class="form-group" style="margin:0">
|
||||
<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>`;
|
||||
@@ -484,6 +537,7 @@ function openTaskModal({ task = null, users = [], reminder = null } = {}, contai
|
||||
content: renderModalContent({ task, users, reminder }),
|
||||
size: 'lg',
|
||||
onSave(panel) {
|
||||
panel.querySelector('.modal-panel__body')?.classList.add('modal-panel__body--tasks-fit');
|
||||
// RRULE-Events binden
|
||||
bindRRuleEvents(document, 'task');
|
||||
|
||||
@@ -493,9 +547,15 @@ function openTaskModal({ task = null, users = [], reminder = null } = {}, contai
|
||||
// Reminder-Toggle: Felder ein-/ausblenden
|
||||
const toggle = panel.querySelector('#reminder-toggle');
|
||||
const fields = panel.querySelector('#reminder-fields');
|
||||
const offset = panel.querySelector('#reminder-offset');
|
||||
const customFields = panel.querySelector('#reminder-custom-fields');
|
||||
toggle?.addEventListener('change', () => {
|
||||
fields.style.display = toggle.checked ? '' : 'none';
|
||||
});
|
||||
offset?.addEventListener('change', () => {
|
||||
if (!customFields) return;
|
||||
customFields.style.display = offset.value === 'offset_custom' ? '' : 'none';
|
||||
});
|
||||
panel.querySelectorAll('.js-date-input').forEach((input) => {
|
||||
input.addEventListener('blur', () => {
|
||||
const parsed = parseDateInput(input.value);
|
||||
@@ -537,9 +597,7 @@ async function handleFormSubmit(e, container) {
|
||||
const dueDate = parseDateInput(dueDateRaw);
|
||||
const rrule = getRRuleValues(document, 'task');
|
||||
const reminderToggle = form.querySelector('#reminder-toggle');
|
||||
const reminderDateRaw = form.querySelector('#reminder-date')?.value || '';
|
||||
const reminderDate = parseDateInput(reminderDateRaw);
|
||||
if (!isDateInputValid(dueDateRaw) || !rrule.valid_until || (reminderToggle?.checked && !isDateInputValid(reminderDateRaw))) {
|
||||
if (!isDateInputValid(dueDateRaw) || !rrule.valid_until) {
|
||||
errorEl.textContent = t('calendar.invalidDate');
|
||||
errorEl.hidden = false;
|
||||
submitBtn.disabled = false;
|
||||
@@ -572,10 +630,27 @@ async function handleFormSubmit(e, container) {
|
||||
|
||||
// Erinnerung speichern oder löschen
|
||||
if (savedTaskId) {
|
||||
const reminderTime = form.querySelector('#reminder-time')?.value || '08:00';
|
||||
|
||||
if (reminderToggle?.checked && reminderDate) {
|
||||
const remindAt = `${reminderDate}T${reminderTime}`;
|
||||
if (reminderToggle?.checked) {
|
||||
if (!dueDate) throw new Error(t('tasks.reminderNeedsDueDate'));
|
||||
const dueDateTime = body.due_time ? new Date(`${dueDate}T${body.due_time}`) : new Date(`${dueDate}T23:59:59`);
|
||||
const offsetPreset = form.querySelector('#reminder-offset')?.value || 'offset_none';
|
||||
if (offsetPreset === 'offset_none') throw new Error(t('tasks.reminderNeedsDueDate'));
|
||||
let offsetMs = 0;
|
||||
if (offsetPreset === 'offset_15m') offsetMs = 15 * 60 * 1000;
|
||||
else if (offsetPreset === 'offset_1h') offsetMs = 60 * 60 * 1000;
|
||||
else if (offsetPreset === 'offset_1d') offsetMs = 24 * 60 * 60 * 1000;
|
||||
else if (offsetPreset === 'offset_2d') offsetMs = 2 * 24 * 60 * 60 * 1000;
|
||||
else if (offsetPreset === 'offset_1w') offsetMs = 7 * 24 * 60 * 60 * 1000;
|
||||
else if (offsetPreset === 'offset_2w') offsetMs = 14 * 24 * 60 * 60 * 1000;
|
||||
else if (offsetPreset === 'offset_custom') {
|
||||
const customAmount = Number(form.querySelector('#reminder-custom-amount')?.value || 0);
|
||||
const customUnit = form.querySelector('#reminder-custom-unit')?.value || 'days';
|
||||
if (!Number.isFinite(customAmount) || customAmount <= 0) throw new Error(t('common.invalidInput'));
|
||||
const unitFactor = customUnit === 'minutes' ? 60000 : customUnit === 'hours' ? 3600000 : customUnit === 'days' ? 86400000 : 604800000;
|
||||
offsetMs = customAmount * unitFactor;
|
||||
}
|
||||
const remindAtDate = new Date(dueDateTime.getTime() - offsetMs);
|
||||
const remindAt = remindAtDate.toISOString().slice(0, 19);
|
||||
await api.post('/reminders', { entity_type: 'task', entity_id: savedTaskId, remind_at: remindAt });
|
||||
refreshReminders();
|
||||
} else if (!reminderToggle?.checked) {
|
||||
@@ -643,6 +718,7 @@ const KANBAN_COLS = () => [
|
||||
{ status: 'open', label: t('tasks.kanbanOpen'), colorVar: '--color-text-secondary' },
|
||||
{ status: 'in_progress', label: t('tasks.kanbanInProgress'), colorVar: '--color-warning' },
|
||||
{ status: 'done', label: t('tasks.kanbanDone'), colorVar: '--color-success' },
|
||||
{ status: 'archived', label: t('tasks.kanbanArchived'), colorVar: '--color-text-tertiary' },
|
||||
];
|
||||
|
||||
function kanbanNextStatus(status) {
|
||||
@@ -1440,6 +1516,16 @@ function wireTaskList(container) {
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'archive-task') {
|
||||
try {
|
||||
await api.patch(`/tasks/${id}/status`, { status: 'archived' });
|
||||
window.oikos.showToast(t('tasks.archivedToast'), 'success');
|
||||
await loadTasks(container);
|
||||
} catch (err) {
|
||||
window.oikos.showToast(err.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'add-subtask') {
|
||||
await handleAddSubtask(target.dataset.parent, container);
|
||||
}
|
||||
|
||||
+4
-1
@@ -25,6 +25,7 @@ const ROUTES = [
|
||||
{ path: '/recipes', page: '/pages/recipes.js', requiresAuth: true, module: 'recipes' },
|
||||
{ path: '/contacts', page: '/pages/contacts.js', requiresAuth: true, module: 'contacts' },
|
||||
{ path: '/budget', page: '/pages/budget.js', requiresAuth: true, module: 'budget' },
|
||||
{ path: '/documents', page: '/pages/documents.js', requiresAuth: true, module: 'documents' },
|
||||
{ path: '/settings', page: '/pages/settings.js', requiresAuth: true, module: 'settings' },
|
||||
];
|
||||
|
||||
@@ -128,7 +129,7 @@ let _pendingLoginRedirect = false;
|
||||
// --------------------------------------------------------
|
||||
|
||||
const ROUTE_ORDER = ['/', '/tasks', '/calendar', '/birthdays', '/meals', '/recipes', '/shopping',
|
||||
'/notes', '/contacts', '/budget', '/settings'];
|
||||
'/notes', '/contacts', '/budget', '/documents', '/settings'];
|
||||
|
||||
const PRIMARY_NAV = 4;
|
||||
|
||||
@@ -181,6 +182,7 @@ function routeTitle(path) {
|
||||
'/notes': t('nav.notes'),
|
||||
'/contacts': t('nav.contacts'),
|
||||
'/budget': t('nav.budget'),
|
||||
'/documents': t('nav.documents'),
|
||||
'/settings': t('nav.settings'),
|
||||
};
|
||||
return map[path] || getAppName();
|
||||
@@ -886,6 +888,7 @@ function navItems() {
|
||||
{ path: '/notes', label: t('nav.notes'), icon: 'sticky-note' },
|
||||
{ path: '/contacts', label: t('nav.contacts'), icon: 'book-user' },
|
||||
{ path: '/budget', label: t('nav.budget'), icon: 'wallet' },
|
||||
{ path: '/documents', label: t('nav.documents'), icon: 'folder-lock' },
|
||||
{ path: '/settings', label: t('nav.settings'), icon: 'settings' },
|
||||
];
|
||||
}
|
||||
|
||||
+5
-5
@@ -106,6 +106,11 @@ export function renderRRuleFields(prefix, existingRule) {
|
||||
<span class="rrule-interval-unit" id="${prefix}-rrule-unit">${unitLabel(parsed.freq, parsed.interval)}</span>
|
||||
</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 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>
|
||||
|
||||
<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>
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1688,6 +1688,7 @@
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rrule-interval-wrap {
|
||||
@@ -1702,6 +1703,11 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rrule-until-field {
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.rrule-day-grid {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
|
||||
@@ -84,13 +84,11 @@
|
||||
}
|
||||
|
||||
.reminder-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3, 12px);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.reminder-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.reminder-fields .modal-grid {
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
* -------------------------------------------------------- */
|
||||
.tasks-page { --module-accent: var(--module-tasks); }
|
||||
|
||||
.modal-panel__body--tasks-fit {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Seiten-Layout
|
||||
* -------------------------------------------------------- */
|
||||
@@ -755,4 +759,3 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -172,6 +172,8 @@
|
||||
--module-birthdays: var(--_module-birthdays); /* Rose - Geburtstage */
|
||||
--_module-budget: #0F766E;
|
||||
--module-budget: var(--_module-budget); /* Teal-700 - Finanzen, Stabilität */
|
||||
--_module-documents: #1D4ED8;
|
||||
--module-documents: var(--_module-documents); /* Blue - secure family documents */
|
||||
--_module-settings: #6E7781;
|
||||
--module-settings: var(--_module-settings); /* Grau - Konfiguration */
|
||||
--_module-reminders: #0E7490;
|
||||
|
||||
+6
-4
@@ -13,10 +13,10 @@
|
||||
* → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit)
|
||||
*/
|
||||
|
||||
const SHELL_CACHE = 'oikos-shell-v65';
|
||||
const PAGES_CACHE = 'oikos-pages-v60';
|
||||
const LOCALES_CACHE = 'oikos-locales-v9';
|
||||
const ASSETS_CACHE = 'oikos-assets-v60';
|
||||
const SHELL_CACHE = 'oikos-shell-v68';
|
||||
const PAGES_CACHE = 'oikos-pages-v63';
|
||||
const LOCALES_CACHE = 'oikos-locales-v12';
|
||||
const ASSETS_CACHE = 'oikos-assets-v63';
|
||||
const BYPASS_CACHE = 'oikos-bypass-flag';
|
||||
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, LOCALES_CACHE, ASSETS_CACHE];
|
||||
|
||||
@@ -47,6 +47,7 @@ const APP_SHELL = [
|
||||
'/styles/contacts.css',
|
||||
'/styles/birthdays.css',
|
||||
'/styles/budget.css',
|
||||
'/styles/documents.css',
|
||||
'/styles/settings.css',
|
||||
'/styles/recipes.css',
|
||||
'/components/oikos-install-prompt.js',
|
||||
@@ -90,6 +91,7 @@ const PAGE_MODULES = [
|
||||
'/pages/contacts.js',
|
||||
'/pages/birthdays.js',
|
||||
'/pages/budget.js',
|
||||
'/pages/documents.js',
|
||||
'/pages/settings.js',
|
||||
'/pages/login.js',
|
||||
'/pages/recipes.js',
|
||||
|
||||
@@ -349,6 +349,76 @@ const MIGRATIONS_SQL = {
|
||||
17: `
|
||||
UPDATE calendar_events SET icon = 'tooth' WHERE icon = 'drill';
|
||||
`,
|
||||
18: `
|
||||
CREATE TABLE tasks_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
||||
priority TEXT NOT NULL DEFAULT 'none'
|
||||
CHECK(priority IN ('none', 'low', 'medium', 'high', 'urgent')),
|
||||
status TEXT NOT NULL DEFAULT 'open'
|
||||
CHECK(status IN ('open', 'in_progress', 'done', 'archived')),
|
||||
due_date TEXT,
|
||||
due_time TEXT,
|
||||
assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
is_recurring INTEGER NOT NULL DEFAULT 0,
|
||||
recurrence_rule TEXT,
|
||||
parent_task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
INSERT INTO tasks_new
|
||||
SELECT * FROM tasks;
|
||||
|
||||
DROP TABLE tasks;
|
||||
ALTER TABLE tasks_new RENAME TO tasks;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_assigned ON tasks(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id);
|
||||
`,
|
||||
19: `
|
||||
CREATE TABLE IF NOT EXISTS family_documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT NOT NULL DEFAULT 'other'
|
||||
CHECK(category IN ('medical', 'school', 'identity', 'insurance', 'finance', 'home', 'vehicle', 'legal', 'travel', 'pets', 'warranty', 'taxes', 'work', 'other')),
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active', 'archived')),
|
||||
visibility TEXT NOT NULL DEFAULT 'family'
|
||||
CHECK(visibility IN ('family', 'restricted', 'private')),
|
||||
original_name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
content_data TEXT NOT NULL,
|
||||
storage_provider TEXT NOT NULL DEFAULT 'local'
|
||||
CHECK(storage_provider IN ('local', 'external')),
|
||||
storage_key TEXT,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS family_document_access (
|
||||
document_id INTEGER NOT NULL REFERENCES family_documents(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
PRIMARY KEY (document_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_family_documents_updated_at
|
||||
AFTER UPDATE ON family_documents FOR EACH ROW
|
||||
BEGIN UPDATE family_documents SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_family_documents_status ON family_documents(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_family_documents_category ON family_documents(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_family_documents_created_by ON family_documents(created_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_family_document_access_user ON family_document_access(user_id);
|
||||
`,
|
||||
};
|
||||
|
||||
export { MIGRATIONS_SQL };
|
||||
|
||||
@@ -775,6 +775,84 @@ const MIGRATIONS = [
|
||||
UPDATE calendar_events SET icon = 'tooth' WHERE icon = 'drill';
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 25,
|
||||
description: 'Allow archived status for tasks',
|
||||
up: `
|
||||
CREATE TABLE tasks_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
||||
priority TEXT NOT NULL DEFAULT 'none'
|
||||
CHECK(priority IN ('none', 'low', 'medium', 'high', 'urgent')),
|
||||
status TEXT NOT NULL DEFAULT 'open'
|
||||
CHECK(status IN ('open', 'in_progress', 'done', 'archived')),
|
||||
due_date TEXT,
|
||||
due_time TEXT,
|
||||
assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
is_recurring INTEGER NOT NULL DEFAULT 0,
|
||||
recurrence_rule TEXT,
|
||||
parent_task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
INSERT INTO tasks_new
|
||||
SELECT * FROM tasks;
|
||||
|
||||
DROP TABLE tasks;
|
||||
ALTER TABLE tasks_new RENAME TO tasks;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_assigned ON tasks(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id);
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 26,
|
||||
description: 'Family documents with local storage metadata and visibility ACL',
|
||||
up: `
|
||||
CREATE TABLE IF NOT EXISTS family_documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT NOT NULL DEFAULT 'other'
|
||||
CHECK(category IN ('medical', 'school', 'identity', 'insurance', 'finance', 'home', 'vehicle', 'legal', 'travel', 'pets', 'warranty', 'taxes', 'work', 'other')),
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active', 'archived')),
|
||||
visibility TEXT NOT NULL DEFAULT 'family'
|
||||
CHECK(visibility IN ('family', 'restricted', 'private')),
|
||||
original_name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
content_data TEXT NOT NULL,
|
||||
storage_provider TEXT NOT NULL DEFAULT 'local'
|
||||
CHECK(storage_provider IN ('local', 'external')),
|
||||
storage_key TEXT,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS family_document_access (
|
||||
document_id INTEGER NOT NULL REFERENCES family_documents(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
PRIMARY KEY (document_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_family_documents_updated_at
|
||||
AFTER UPDATE ON family_documents FOR EACH ROW
|
||||
BEGIN UPDATE family_documents SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_family_documents_status ON family_documents(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_family_documents_category ON family_documents(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_family_documents_created_by ON family_documents(created_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_family_document_access_user ON family_document_access(user_id);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,6 +27,7 @@ import notesRouter from './routes/notes.js';
|
||||
import contactsRouter from './routes/contacts.js';
|
||||
import birthdaysRouter from './routes/birthdays.js';
|
||||
import budgetRouter from './routes/budget.js';
|
||||
import documentsRouter from './routes/documents.js';
|
||||
import weatherRouter from './routes/weather.js';
|
||||
import preferencesRouter from './routes/preferences.js';
|
||||
import remindersRouter from './routes/reminders.js';
|
||||
@@ -200,6 +201,7 @@ app.use('/api/v1/notes', notesRouter);
|
||||
app.use('/api/v1/contacts', contactsRouter);
|
||||
app.use('/api/v1/birthdays', birthdaysRouter);
|
||||
app.use('/api/v1/budget', budgetRouter);
|
||||
app.use('/api/v1/documents', documentsRouter);
|
||||
app.use('/api/v1/weather', weatherRouter);
|
||||
app.use('/api/v1/preferences', preferencesRouter);
|
||||
app.use('/api/v1/reminders', remindersRouter);
|
||||
|
||||
@@ -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;
|
||||
@@ -19,7 +19,7 @@ const router = express.Router();
|
||||
// --------------------------------------------------------
|
||||
|
||||
const VALID_PRIORITIES = ['none', 'low', 'medium', 'high', 'urgent'];
|
||||
const VALID_STATUSES = ['open', 'in_progress', 'done'];
|
||||
const VALID_STATUSES = ['open', 'in_progress', 'done', 'archived'];
|
||||
const VALID_CATEGORIES = ['household', 'school', 'shopping', 'repair',
|
||||
'health', 'finance', 'leisure', 'misc'];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user