Merge branch 'main' of github.com:rafaelfoster/oikos
This commit is contained in:
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.32.0] - 2026-04-29
|
||||
|
||||
### Added
|
||||
- Documents: new Family Documents module — upload, search, and manage family files (PDF, images, text, Office) with grid/list view, per-document visibility (family, selected members, private), category tagging (medical, school, identity, insurance, finance, home, vehicle, legal, travel, pets, warranty, taxes, work, other), archive/restore, and download actions (#104)
|
||||
- Documents: drag-and-drop upload area in the new-document modal (#104)
|
||||
- Tasks: archive button on task cards; archived status supported in kanban view and filter (#104)
|
||||
- Tasks: inline reminder preset UI — offset from due date/time with 15 min, 1 h, 1 d, 2 d, 1 w, 2 w, or custom offset presets (#104)
|
||||
- i18n: Documents and updated Tasks keys translated in all 15 locales
|
||||
|
||||
### Fixed
|
||||
- Modal: discard-changes confirmation no longer corrupts overlay state when a confirm dialog is triggered from within another modal (#104)
|
||||
- RRule: "Until" date field moved inside the recurrence options row for better layout (#104)
|
||||
|
||||
## [0.31.2] - 2026-04-29
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "oikos",
|
||||
"version": "0.31.1",
|
||||
"version": "0.32.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "oikos",
|
||||
"version": "0.31.1",
|
||||
"version": "0.32.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oikos",
|
||||
"version": "0.31.2",
|
||||
"version": "0.32.0",
|
||||
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
|
||||
"main": "server/index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+63
-1
@@ -46,7 +46,8 @@
|
||||
"navigation": "Navigation",
|
||||
"quickActions": "Quick actions",
|
||||
"recipes": "Recipes",
|
||||
"more": "More"
|
||||
"more": "More",
|
||||
"documents": "Documents"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Overview",
|
||||
@@ -921,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}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+63
-1
@@ -46,7 +46,8 @@
|
||||
"navigation": "Navegação",
|
||||
"quickActions": "Ações rápidas",
|
||||
"recipes": "Receitas",
|
||||
"more": "Mais"
|
||||
"more": "Mais",
|
||||
"documents": "Documentos"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Painel",
|
||||
@@ -903,5 +904,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`;
|
||||
}
|
||||
+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' },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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-v66';
|
||||
const PAGES_CACHE = 'oikos-pages-v61';
|
||||
const LOCALES_CACHE = 'oikos-locales-v10';
|
||||
const ASSETS_CACHE = 'oikos-assets-v61';
|
||||
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',
|
||||
|
||||
@@ -380,6 +380,45 @@ const MIGRATIONS_SQL = {
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_assigned ON tasks(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id);
|
||||
`,
|
||||
19: `
|
||||
CREATE TABLE IF NOT EXISTS family_documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT NOT NULL DEFAULT 'other'
|
||||
CHECK(category IN ('medical', 'school', 'identity', 'insurance', 'finance', 'home', 'vehicle', 'legal', 'travel', 'pets', 'warranty', 'taxes', 'work', 'other')),
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active', 'archived')),
|
||||
visibility TEXT NOT NULL DEFAULT 'family'
|
||||
CHECK(visibility IN ('family', 'restricted', 'private')),
|
||||
original_name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
content_data TEXT NOT NULL,
|
||||
storage_provider TEXT NOT NULL DEFAULT 'local'
|
||||
CHECK(storage_provider IN ('local', 'external')),
|
||||
storage_key TEXT,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS family_document_access (
|
||||
document_id INTEGER NOT NULL REFERENCES family_documents(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
PRIMARY KEY (document_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_family_documents_updated_at
|
||||
AFTER UPDATE ON family_documents FOR EACH ROW
|
||||
BEGIN UPDATE family_documents SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_family_documents_status ON family_documents(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_family_documents_category ON family_documents(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_family_documents_created_by ON family_documents(created_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_family_document_access_user ON family_document_access(user_id);
|
||||
`,
|
||||
};
|
||||
|
||||
export { MIGRATIONS_SQL };
|
||||
|
||||
@@ -810,6 +810,49 @@ const MIGRATIONS = [
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id);
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 26,
|
||||
description: 'Family documents with local storage metadata and visibility ACL',
|
||||
up: `
|
||||
CREATE TABLE IF NOT EXISTS family_documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT NOT NULL DEFAULT 'other'
|
||||
CHECK(category IN ('medical', 'school', 'identity', 'insurance', 'finance', 'home', 'vehicle', 'legal', 'travel', 'pets', 'warranty', 'taxes', 'work', 'other')),
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active', 'archived')),
|
||||
visibility TEXT NOT NULL DEFAULT 'family'
|
||||
CHECK(visibility IN ('family', 'restricted', 'private')),
|
||||
original_name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
content_data TEXT NOT NULL,
|
||||
storage_provider TEXT NOT NULL DEFAULT 'local'
|
||||
CHECK(storage_provider IN ('local', 'external')),
|
||||
storage_key TEXT,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS family_document_access (
|
||||
document_id INTEGER NOT NULL REFERENCES family_documents(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
PRIMARY KEY (document_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_family_documents_updated_at
|
||||
AFTER UPDATE ON family_documents FOR EACH ROW
|
||||
BEGIN UPDATE family_documents SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_family_documents_status ON family_documents(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_family_documents_category ON family_documents(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_family_documents_created_by ON family_documents(created_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_family_document_access_user ON family_document_access(user_id);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user