From 9759f5e2671881d8b785ca519f4c90bc68a75d78 Mon Sep 17 00:00:00 2001 From: Rafael Foster Date: Wed, 29 Apr 2026 05:33:06 -0300 Subject: [PATCH 1/7] feat(tasks): add archived status support in API and schema --- server/db-schema-test.js | 31 +++++++++++++++++++++++++++++++ server/db.js | 35 +++++++++++++++++++++++++++++++++++ server/routes/tasks.js | 2 +- 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/server/db-schema-test.js b/server/db-schema-test.js index 99c29cb..7889e90 100644 --- a/server/db-schema-test.js +++ b/server/db-schema-test.js @@ -349,6 +349,37 @@ const MIGRATIONS_SQL = { 17: ` UPDATE calendar_events SET icon = 'tooth' WHERE icon = 'drill'; `, + 18: ` + CREATE TABLE tasks_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + category TEXT NOT NULL DEFAULT 'Sonstiges', + priority TEXT NOT NULL DEFAULT 'none' + CHECK(priority IN ('none', 'low', 'medium', 'high', 'urgent')), + status TEXT NOT NULL DEFAULT 'open' + CHECK(status IN ('open', 'in_progress', 'done', 'archived')), + due_date TEXT, + due_time TEXT, + assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + is_recurring INTEGER NOT NULL DEFAULT 0, + recurrence_rule TEXT, + parent_task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + INSERT INTO tasks_new + SELECT * FROM tasks; + + DROP TABLE tasks; + ALTER TABLE tasks_new RENAME TO tasks; + + CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); + CREATE INDEX IF NOT EXISTS idx_tasks_assigned ON tasks(assigned_to); + CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id); + `, }; export { MIGRATIONS_SQL }; diff --git a/server/db.js b/server/db.js index a42d3a7..30d81a8 100644 --- a/server/db.js +++ b/server/db.js @@ -775,6 +775,41 @@ const MIGRATIONS = [ UPDATE calendar_events SET icon = 'tooth' WHERE icon = 'drill'; `, }, + { + version: 25, + description: 'Allow archived status for tasks', + up: ` + CREATE TABLE tasks_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + category TEXT NOT NULL DEFAULT 'Sonstiges', + priority TEXT NOT NULL DEFAULT 'none' + CHECK(priority IN ('none', 'low', 'medium', 'high', 'urgent')), + status TEXT NOT NULL DEFAULT 'open' + CHECK(status IN ('open', 'in_progress', 'done', 'archived')), + due_date TEXT, + due_time TEXT, + assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + is_recurring INTEGER NOT NULL DEFAULT 0, + recurrence_rule TEXT, + parent_task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + INSERT INTO tasks_new + SELECT * FROM tasks; + + DROP TABLE tasks; + ALTER TABLE tasks_new RENAME TO tasks; + + CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); + CREATE INDEX IF NOT EXISTS idx_tasks_assigned ON tasks(assigned_to); + CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id); + `, + }, ]; /** diff --git a/server/routes/tasks.js b/server/routes/tasks.js index 65afef5..788f9ae 100644 --- a/server/routes/tasks.js +++ b/server/routes/tasks.js @@ -19,7 +19,7 @@ const router = express.Router(); // -------------------------------------------------------- const VALID_PRIORITIES = ['none', 'low', 'medium', 'high', 'urgent']; -const VALID_STATUSES = ['open', 'in_progress', 'done']; +const VALID_STATUSES = ['open', 'in_progress', 'done', 'archived']; const VALID_CATEGORIES = ['household', 'school', 'shopping', 'repair', 'health', 'finance', 'leisure', 'misc']; From 0e7142edc25c48d36527e1f5a0b77ab8056dca33 Mon Sep 17 00:00:00 2001 From: Rafael Foster Date: Wed, 29 Apr 2026 05:33:06 -0300 Subject: [PATCH 2/7] feat(tasks): advanced reminders UI and recurrence layout improvements --- public/locales/en.json | 5 +- public/locales/pt.json | 5 +- public/pages/tasks.js | 104 ++++++++++++++++++++++++++++++++------- public/rrule-ui.js | 10 ++-- public/styles/layout.css | 6 +++ public/sw.js | 8 +-- 6 files changed, 110 insertions(+), 28 deletions(-) diff --git a/public/locales/en.json b/public/locales/en.json index 3bcf10b..2757a44 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -131,6 +131,7 @@ "statusOpen": "Open", "statusInProgress": "In Progress", "statusDone": "Done", + "statusArchived": "Archived", "categoryHousehold": "Household", "categorySchool": "School", "categoryShopping": "Shopping", @@ -167,6 +168,7 @@ "kanbanOpen": "Open", "kanbanInProgress": "In Progress", "kanbanDone": "Done", + "kanbanArchived": "Archived", "kanbanMoveToInProgress": "Set to in progress", "kanbanMoveToDone": "Mark as done", "kanbanMoveToOpen": "Reopen", @@ -179,7 +181,8 @@ "filterGroupPriority": "Priority", "filterGroupStatus": "Status", "swipedDoneToast": "Marked as done.", - "swipedOpenToast": "Marked as open." + "swipedOpenToast": "Marked as open.", + "reminderNeedsDueDate": "Set a due date to enable task reminders." }, "shopping": { "title": "Shopping", diff --git a/public/locales/pt.json b/public/locales/pt.json index 9a85cea..c588e78 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -131,6 +131,7 @@ "statusOpen": "Aberto", "statusInProgress": "Em andamento", "statusDone": "Concluído", + "statusArchived": "Arquivado", "categoryHousehold": "Casa", "categorySchool": "Escola", "categoryShopping": "Compras", @@ -167,6 +168,7 @@ "kanbanOpen": "Aberto", "kanbanInProgress": "Em andamento", "kanbanDone": "Concluído", + "kanbanArchived": "Arquivado", "kanbanMoveToInProgress": "Mover para em andamento", "kanbanMoveToDone": "Marcar como concluído", "kanbanMoveToOpen": "Reabrir", @@ -179,7 +181,8 @@ "filterGroupPriority": "Prioridade", "filterGroupStatus": "Estado", "swipedDoneToast": "Marcado como concluído.", - "swipedOpenToast": "Marcado como aberto." + "swipedOpenToast": "Marcado como aberto.", + "reminderNeedsDueDate": "Defina uma data de vencimento para habilitar lembretes da tarefa." }, "shopping": { "title": "Compras", diff --git a/public/pages/tasks.js b/public/pages/tasks.js index 2cd1932..9f8d0fd 100644 --- a/public/pages/tasks.js +++ b/public/pages/tasks.js @@ -30,6 +30,7 @@ const STATUSES = () => [ { value: 'open', label: t('tasks.statusOpen') }, { value: 'in_progress', label: t('tasks.statusInProgress') }, { value: 'done', label: t('tasks.statusDone') }, + { value: 'archived', label: t('tasks.statusArchived') }, ]; const CATEGORIES = [ @@ -376,7 +377,7 @@ function renderModalContent({ task = null, users = [], reminder = null } = {}) { ${renderRRuleFields('task', task?.recurrence_rule)} - ${renderReminderSection(reminder)} + ${renderReminderSection(task, reminder)} @@ -446,10 +447,36 @@ async function loadReminderForTask(taskId) { } } -function renderReminderSection(reminder = null) { - const hasReminder = !!reminder; - const remindDate = hasReminder ? reminder.remind_at.slice(0, 10) : ''; - const remindTime = hasReminder ? reminder.remind_at.slice(11, 16) : ''; +function parseOffsetMsFromReminder(task, reminder) { + if (!task?.due_date || !reminder?.remind_at) return null; + const due = task.due_time ? new Date(`${task.due_date}T${task.due_time}`) : new Date(`${task.due_date}T23:59:59`); + const remind = new Date(reminder.remind_at); + if (Number.isNaN(due.getTime()) || Number.isNaN(remind.getTime())) return null; + return due.getTime() - remind.getTime(); +} + +function resolveReminderPreset(task, reminder) { + const offset = parseOffsetMsFromReminder(task, reminder); + if (offset === null) return { preset: 'offset_15m', amount: '15', unit: 'minutes' }; + const map = new Map([ + [0, 'offset_at_time'], + [15 * 60 * 1000, 'offset_15m'], + [60 * 60 * 1000, 'offset_1h'], + [24 * 60 * 60 * 1000, 'offset_1d'], + [2 * 24 * 60 * 60 * 1000, 'offset_2d'], + [7 * 24 * 60 * 60 * 1000, 'offset_1w'], + [14 * 24 * 60 * 60 * 1000, 'offset_2w'], + ]); + if (map.has(offset)) return { preset: map.get(offset), amount: '1', unit: 'days' }; + const minutes = Math.round(offset / 60000); + if (minutes > 0) return { preset: 'offset_custom', amount: String(minutes), unit: 'minutes' }; + return { preset: 'offset_at_time', amount: '1', unit: 'days' }; +} + +function renderReminderSection(task = null, reminder = null) { + const hasReminder = !!reminder; + const resolved = resolveReminderPreset(task, reminder); + const showCustom = hasReminder && resolved.preset === 'offset_custom'; return `
@@ -462,12 +489,33 @@ function renderReminderSection(reminder = null) {
- - + +
-
- - +
`; @@ -493,9 +541,15 @@ function openTaskModal({ task = null, users = [], reminder = null } = {}, contai // Reminder-Toggle: Felder ein-/ausblenden const toggle = panel.querySelector('#reminder-toggle'); const fields = panel.querySelector('#reminder-fields'); + const offset = panel.querySelector('#reminder-offset'); + const customFields = panel.querySelector('#reminder-custom-fields'); toggle?.addEventListener('change', () => { fields.style.display = toggle.checked ? '' : 'none'; }); + offset?.addEventListener('change', () => { + if (!customFields) return; + customFields.style.display = offset.value === 'offset_custom' ? '' : 'none'; + }); panel.querySelectorAll('.js-date-input').forEach((input) => { input.addEventListener('blur', () => { const parsed = parseDateInput(input.value); @@ -537,9 +591,7 @@ async function handleFormSubmit(e, container) { const dueDate = parseDateInput(dueDateRaw); const rrule = getRRuleValues(document, 'task'); const reminderToggle = form.querySelector('#reminder-toggle'); - const reminderDateRaw = form.querySelector('#reminder-date')?.value || ''; - const reminderDate = parseDateInput(reminderDateRaw); - if (!isDateInputValid(dueDateRaw) || !rrule.valid_until || (reminderToggle?.checked && !isDateInputValid(reminderDateRaw))) { + if (!isDateInputValid(dueDateRaw) || !rrule.valid_until) { errorEl.textContent = t('calendar.invalidDate'); errorEl.hidden = false; submitBtn.disabled = false; @@ -572,10 +624,27 @@ async function handleFormSubmit(e, container) { // Erinnerung speichern oder löschen if (savedTaskId) { - const reminderTime = form.querySelector('#reminder-time')?.value || '08:00'; - - if (reminderToggle?.checked && reminderDate) { - const remindAt = `${reminderDate}T${reminderTime}`; + if (reminderToggle?.checked) { + if (!dueDate) throw new Error(t('tasks.reminderNeedsDueDate')); + const dueDateTime = body.due_time ? new Date(`${dueDate}T${body.due_time}`) : new Date(`${dueDate}T23:59:59`); + const offsetPreset = form.querySelector('#reminder-offset')?.value || 'offset_none'; + if (offsetPreset === 'offset_none') throw new Error(t('tasks.reminderNeedsDueDate')); + let offsetMs = 0; + if (offsetPreset === 'offset_15m') offsetMs = 15 * 60 * 1000; + else if (offsetPreset === 'offset_1h') offsetMs = 60 * 60 * 1000; + else if (offsetPreset === 'offset_1d') offsetMs = 24 * 60 * 60 * 1000; + else if (offsetPreset === 'offset_2d') offsetMs = 2 * 24 * 60 * 60 * 1000; + else if (offsetPreset === 'offset_1w') offsetMs = 7 * 24 * 60 * 60 * 1000; + else if (offsetPreset === 'offset_2w') offsetMs = 14 * 24 * 60 * 60 * 1000; + else if (offsetPreset === 'offset_custom') { + const customAmount = Number(form.querySelector('#reminder-custom-amount')?.value || 0); + const customUnit = form.querySelector('#reminder-custom-unit')?.value || 'days'; + if (!Number.isFinite(customAmount) || customAmount <= 0) throw new Error(t('common.invalidInput')); + const unitFactor = customUnit === 'minutes' ? 60000 : customUnit === 'hours' ? 3600000 : customUnit === 'days' ? 86400000 : 604800000; + offsetMs = customAmount * unitFactor; + } + const remindAtDate = new Date(dueDateTime.getTime() - offsetMs); + const remindAt = remindAtDate.toISOString().slice(0, 19); await api.post('/reminders', { entity_type: 'task', entity_id: savedTaskId, remind_at: remindAt }); refreshReminders(); } else if (!reminderToggle?.checked) { @@ -643,6 +712,7 @@ const KANBAN_COLS = () => [ { status: 'open', label: t('tasks.kanbanOpen'), colorVar: '--color-text-secondary' }, { status: 'in_progress', label: t('tasks.kanbanInProgress'), colorVar: '--color-warning' }, { status: 'done', label: t('tasks.kanbanDone'), colorVar: '--color-success' }, + { status: 'archived', label: t('tasks.kanbanArchived'), colorVar: '--color-text-tertiary' }, ]; function kanbanNextStatus(status) { diff --git a/public/rrule-ui.js b/public/rrule-ui.js index 67deb64..f169b40 100644 --- a/public/rrule-ui.js +++ b/public/rrule-ui.js @@ -106,6 +106,11 @@ export function renderRRuleFields(prefix, existingRule) { ${unitLabel(parsed.freq, parsed.interval)} +
+ + +
@@ -113,11 +118,6 @@ export function renderRRuleFields(prefix, existingRule) {
${dayBtns}
-
- - -
`; diff --git a/public/styles/layout.css b/public/styles/layout.css index f58a965..8b75702 100755 --- a/public/styles/layout.css +++ b/public/styles/layout.css @@ -1688,6 +1688,7 @@ display: flex; align-items: flex-end; gap: var(--space-3); + flex-wrap: wrap; } .rrule-interval-wrap { @@ -1702,6 +1703,11 @@ white-space: nowrap; } +.rrule-until-field { + flex: 1; + min-width: 220px; +} + .rrule-day-grid { display: flex; gap: var(--space-1); diff --git a/public/sw.js b/public/sw.js index 4b17d5c..7272c5a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -13,10 +13,10 @@ * → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit) */ -const SHELL_CACHE = 'oikos-shell-v65'; -const PAGES_CACHE = 'oikos-pages-v60'; -const LOCALES_CACHE = 'oikos-locales-v9'; -const ASSETS_CACHE = 'oikos-assets-v60'; +const SHELL_CACHE = 'oikos-shell-v66'; +const PAGES_CACHE = 'oikos-pages-v61'; +const LOCALES_CACHE = 'oikos-locales-v10'; +const ASSETS_CACHE = 'oikos-assets-v61'; const BYPASS_CACHE = 'oikos-bypass-flag'; const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, LOCALES_CACHE, ASSETS_CACHE]; From 6a47cda9a9433f235b91b593a38e54d9da0a9313 Mon Sep 17 00:00:00 2001 From: Rafael Foster Date: Wed, 29 Apr 2026 05:43:04 -0300 Subject: [PATCH 3/7] fix(tasks): remove extra modal space when enabling reminders --- public/styles/reminders.css | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/public/styles/reminders.css b/public/styles/reminders.css index f9e0d50..7c0018b 100644 --- a/public/styles/reminders.css +++ b/public/styles/reminders.css @@ -84,13 +84,11 @@ } .reminder-fields { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + display: flex; + flex-direction: column; gap: var(--space-3, 12px); } -@media (max-width: 480px) { - .reminder-fields { - grid-template-columns: 1fr; - } +.reminder-fields .modal-grid { + align-items: end; } From 72f103af04e771766d5e63f01ef4358327b20948 Mon Sep 17 00:00:00 2001 From: Rafael Foster Date: Wed, 29 Apr 2026 05:45:41 -0300 Subject: [PATCH 4/7] fix(tasks): make task modal body size to content when reminder is enabled --- public/pages/tasks.js | 1 + public/styles/tasks.css | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/public/pages/tasks.js b/public/pages/tasks.js index 9f8d0fd..ebc5909 100644 --- a/public/pages/tasks.js +++ b/public/pages/tasks.js @@ -532,6 +532,7 @@ function openTaskModal({ task = null, users = [], reminder = null } = {}, contai content: renderModalContent({ task, users, reminder }), size: 'lg', onSave(panel) { + panel.querySelector('.modal-panel__body')?.classList.add('modal-panel__body--tasks-fit'); // RRULE-Events binden bindRRuleEvents(document, 'task'); diff --git a/public/styles/tasks.css b/public/styles/tasks.css index 663625e..b714bff 100644 --- a/public/styles/tasks.css +++ b/public/styles/tasks.css @@ -9,6 +9,10 @@ * -------------------------------------------------------- */ .tasks-page { --module-accent: var(--module-tasks); } +.modal-panel__body--tasks-fit { + flex: 0 1 auto; +} + /* -------------------------------------------------------- * Seiten-Layout * -------------------------------------------------------- */ @@ -755,4 +759,3 @@ opacity: 0.5; } - From 6eafe80395c1e550ae16549d0a831df6833a47f8 Mon Sep 17 00:00:00 2001 From: Rafael Foster Date: Wed, 29 Apr 2026 05:51:52 -0300 Subject: [PATCH 5/7] feat(tasks): add archive button in list cards --- public/locales/en.json | 4 +++- public/locales/pt.json | 4 +++- public/pages/tasks.js | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/public/locales/en.json b/public/locales/en.json index 0da3ce8..29f870b 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -153,6 +153,7 @@ "markDone": "Mark {{title}} as done", "markOpen": "Mark {{title}} as open", "editButton": "Edit task", + "archiveButton": "Archive task", "swipeOpen": "Reopen", "swipeDone": "Done", "swipeEdit": "Edit", @@ -163,6 +164,7 @@ "savedToast": "Task saved.", "createdToast": "Task created.", "deletedToast": "Task deleted.", + "archivedToast": "Task archived.", "loadError": "Task could not be loaded.", "subtaskPrompt": "Subtask:", "kanbanOpen": "Open", @@ -920,4 +922,4 @@ "birthdays": "Add birthdays — you will receive a reminder in time.", "recipes": "Create recipes and link them to your meal planner." } -} \ No newline at end of file +} diff --git a/public/locales/pt.json b/public/locales/pt.json index a684908..d4db15c 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -153,6 +153,7 @@ "markDone": "Marcar {{title}} como concluído", "markOpen": "Marcar {{title}} como pendente", "editButton": "Editar tarefa", + "archiveButton": "Arquivar tarefa", "swipeOpen": "Abrir", "swipeDone": "Concluído", "swipeEdit": "Editar", @@ -163,6 +164,7 @@ "savedToast": "Tarefa salva.", "createdToast": "Tarefa criada.", "deletedToast": "Tarefa excluída.", + "archivedToast": "Tarefa arquivada.", "loadError": "Falha ao carregar a tarefa.", "subtaskPrompt": "Subtarefa:", "kanbanOpen": "Aberto", @@ -902,4 +904,4 @@ "emptyHint": { "recipes": "Crie receitas e vincule-as ao seu planejador de refeições." } -} \ No newline at end of file +} diff --git a/public/pages/tasks.js b/public/pages/tasks.js index ebc5909..2d513b1 100644 --- a/public/pages/tasks.js +++ b/public/pages/tasks.js @@ -209,6 +209,11 @@ function renderTaskCard(task, opts = {}) { aria-label="${t('tasks.editButton')}"> + ${task.status !== 'archived' ? ` + ` : ''} ${progress !== null ? ` @@ -1511,6 +1516,16 @@ function wireTaskList(container) { } } + if (action === 'archive-task') { + try { + await api.patch(`/tasks/${id}/status`, { status: 'archived' }); + window.oikos.showToast(t('tasks.archivedToast'), 'success'); + await loadTasks(container); + } catch (err) { + window.oikos.showToast(err.message, 'danger'); + } + } + if (action === 'add-subtask') { await handleAddSubtask(target.dataset.parent, container); } From 72fca9206641b16071cf88008f8d9d8248a5c055 Mon Sep 17 00:00:00 2001 From: Rafael Foster Date: Wed, 29 Apr 2026 06:14:29 -0300 Subject: [PATCH 6/7] feat(documents): add family document management --- public/locales/ar.json | 63 +++++- public/locales/de.json | 63 +++++- public/locales/el.json | 63 +++++- public/locales/en.json | 61 +++++- public/locales/es.json | 63 +++++- public/locales/fr.json | 63 +++++- public/locales/hi.json | 63 +++++- public/locales/it.json | 63 +++++- public/locales/ja.json | 63 +++++- public/locales/pt.json | 61 +++++- public/locales/ru.json | 63 +++++- public/locales/sv.json | 63 +++++- public/locales/tr.json | 63 +++++- public/locales/uk.json | 63 +++++- public/locales/zh.json | 63 +++++- public/pages/documents.js | 377 ++++++++++++++++++++++++++++++++++++ public/router.js | 5 +- public/styles/documents.css | 279 ++++++++++++++++++++++++++ public/styles/tokens.css | 2 + public/sw.js | 10 +- server/db-schema-test.js | 39 ++++ server/db.js | 43 ++++ server/index.js | 2 + server/routes/documents.js | 262 +++++++++++++++++++++++++ 24 files changed, 1927 insertions(+), 33 deletions(-) create mode 100644 public/pages/documents.js create mode 100644 public/styles/documents.css create mode 100644 server/routes/documents.js diff --git a/public/locales/ar.json b/public/locales/ar.json index 5a2fafb..25ac6a0 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -46,7 +46,8 @@ "navigation": "التنقل", "quickActions": "الإجراءات السريعة", "recipes": "الوصفات", - "more": "المزيد" + "more": "المزيد", + "documents": "المستندات" }, "dashboard": { "title": "لوحة التحكم", @@ -897,5 +898,63 @@ }, "emptyHint": { "recipes": "أنشئ وصفات واربطها بمخطط الوجبات." + }, + "documents": { + "title": "المستندات", + "addButton": "إضافة مستند", + "searchPlaceholder": "البحث في المستندات...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "كل الفئات", + "emptyTitle": "لا توجد مستندات بعد", + "emptyDescription": "ارفع مستندات العائلة وتحكم في من يمكنه رؤية كل ملف.", + "newTitle": "مستند جديد", + "editTitle": "إعدادات المستند", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "كل العائلة", + "restricted": "أعضاء محددون", + "private": "أنا فقط" + }, + "category": { + "medical": "طبي", + "school": "مدرسة", + "identity": "هوية", + "insurance": "تأمين", + "finance": "مالية", + "home": "منزل", + "vehicle": "مركبة", + "legal": "قانوني", + "travel": "سفر", + "pets": "حيوانات أليفة", + "warranty": "ضمان", + "taxes": "ضرائب", + "work": "عمل", + "other": "أخرى" + } } -} \ No newline at end of file +} diff --git a/public/locales/de.json b/public/locales/de.json index 7a6e61e..01cb775 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -46,7 +46,8 @@ "navigation": "Navigation", "quickActions": "Schnellaktionen", "more": "Mehr", - "recipes": "Rezepte" + "recipes": "Rezepte", + "documents": "Dokumente" }, "search": { "title": "Suche", @@ -935,5 +936,63 @@ "goCal": "Kalender", "goShop": "Einkaufsliste", "goNotes": "Notizen" + }, + "documents": { + "title": "Dokumente", + "addButton": "Dokument hinzufügen", + "searchPlaceholder": "Dokumente suchen...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "Alle Kategorien", + "emptyTitle": "Noch keine Dokumente", + "emptyDescription": "Lade Familiendokumente hoch und steuere, wer jede Datei sehen darf.", + "newTitle": "Neues Dokument", + "editTitle": "Dokumenteinstellungen", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "Ganze Familie", + "restricted": "Ausgewählte Mitglieder", + "private": "Nur ich" + }, + "category": { + "medical": "Medizin", + "school": "Schule", + "identity": "Identität", + "insurance": "Versicherung", + "finance": "Finanzen", + "home": "Zuhause", + "vehicle": "Fahrzeug", + "legal": "Rechtliches", + "travel": "Reisen", + "pets": "Haustiere", + "warranty": "Garantie", + "taxes": "Steuern", + "work": "Arbeit", + "other": "Sonstiges" + } } -} \ No newline at end of file +} diff --git a/public/locales/el.json b/public/locales/el.json index 612be4a..434d361 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -46,7 +46,8 @@ "navigation": "Πλοήγηση", "quickActions": "Γρήγορες ενέργειες", "recipes": "Συνταγές", - "more": "Περισσότερα" + "more": "Περισσότερα", + "documents": "Έγγραφα" }, "dashboard": { "title": "Επισκόπηση", @@ -897,5 +898,63 @@ }, "emptyHint": { "recipes": "Δημιουργήστε συνταγές και συνδέστε τις με τον προγραμματισμό γευμάτων." + }, + "documents": { + "title": "Έγγραφα", + "addButton": "Προσθήκη εγγράφου", + "searchPlaceholder": "Αναζήτηση εγγράφων...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "Όλες οι κατηγορίες", + "emptyTitle": "Δεν υπάρχουν έγγραφα ακόμα", + "emptyDescription": "Ανεβάστε οικογενειακά έγγραφα και ελέγξτε ποιος μπορεί να βλέπει κάθε αρχείο.", + "newTitle": "Νέο έγγραφο", + "editTitle": "Ρυθμίσεις εγγράφου", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "Όλη η οικογένεια", + "restricted": "Επιλεγμένα μέλη", + "private": "Μόνο εγώ" + }, + "category": { + "medical": "Ιατρικά", + "school": "Σχολείο", + "identity": "Ταυτότητα", + "insurance": "Ασφάλιση", + "finance": "Οικονομικά", + "home": "Σπίτι", + "vehicle": "Όχημα", + "legal": "Νομικά", + "travel": "Ταξίδια", + "pets": "Κατοικίδια", + "warranty": "Εγγύηση", + "taxes": "Φόροι", + "work": "Εργασία", + "other": "Άλλο" + } } -} \ No newline at end of file +} diff --git a/public/locales/en.json b/public/locales/en.json index 29f870b..de7002a 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -46,7 +46,8 @@ "navigation": "Navigation", "quickActions": "Quick actions", "recipes": "Recipes", - "more": "More" + "more": "More", + "documents": "Documents" }, "dashboard": { "title": "Overview", @@ -921,5 +922,63 @@ "meals": "Plan meals for the week and link recipes.", "birthdays": "Add birthdays — you will receive a reminder in time.", "recipes": "Create recipes and link them to your meal planner." + }, + "documents": { + "title": "Documents", + "addButton": "Add document", + "searchPlaceholder": "Search documents...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "All categories", + "emptyTitle": "No documents yet", + "emptyDescription": "Upload family documents and control who can see each file.", + "newTitle": "New document", + "editTitle": "Document settings", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "Entire family", + "restricted": "Selected members", + "private": "Only me" + }, + "category": { + "medical": "Medical", + "school": "School", + "identity": "Identity", + "insurance": "Insurance", + "finance": "Finance", + "home": "Home", + "vehicle": "Vehicle", + "legal": "Legal", + "travel": "Travel", + "pets": "Pets", + "warranty": "Warranty", + "taxes": "Taxes", + "work": "Work", + "other": "Other" + } } } diff --git a/public/locales/es.json b/public/locales/es.json index 2be26bd..b75d023 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -46,7 +46,8 @@ "navigation": "Navegación", "quickActions": "Acciones rápidas", "recipes": "Recetas", - "more": "Más" + "more": "Más", + "documents": "Documentos" }, "dashboard": { "title": "Inicio", @@ -897,5 +898,63 @@ }, "emptyHint": { "recipes": "Crea recetas y vincúlalas con tu planificador de comidas." + }, + "documents": { + "title": "Documentos", + "addButton": "Agregar documento", + "searchPlaceholder": "Buscar documentos...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "Todas las categorías", + "emptyTitle": "Aún no hay documentos", + "emptyDescription": "Sube documentos familiares y controla quién puede ver cada archivo.", + "newTitle": "Nuevo documento", + "editTitle": "Configuración del documento", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "Toda la familia", + "restricted": "Miembros seleccionados", + "private": "Solo yo" + }, + "category": { + "medical": "Médico", + "school": "Escuela", + "identity": "Identidad", + "insurance": "Seguro", + "finance": "Finanzas", + "home": "Hogar", + "vehicle": "Vehículo", + "legal": "Legal", + "travel": "Viajes", + "pets": "Mascotas", + "warranty": "Garantía", + "taxes": "Impuestos", + "work": "Trabajo", + "other": "Otros" + } } -} \ No newline at end of file +} diff --git a/public/locales/fr.json b/public/locales/fr.json index bd2e99d..552f271 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -46,7 +46,8 @@ "navigation": "Navigation", "quickActions": "Actions rapides", "recipes": "Recettes", - "more": "Plus" + "more": "Plus", + "documents": "Documents" }, "dashboard": { "title": "Accueil", @@ -897,5 +898,63 @@ }, "emptyHint": { "recipes": "Créez des recettes et associez-les à votre planification des repas." + }, + "documents": { + "title": "Documents", + "addButton": "Ajouter un document", + "searchPlaceholder": "Rechercher des documents...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "Toutes les catégories", + "emptyTitle": "Aucun document pour le moment", + "emptyDescription": "Ajoutez des documents familiaux et contrôlez qui peut voir chaque fichier.", + "newTitle": "Nouveau document", + "editTitle": "Paramètres du document", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "Toute la famille", + "restricted": "Membres sélectionnés", + "private": "Moi uniquement" + }, + "category": { + "medical": "Médical", + "school": "École", + "identity": "Identité", + "insurance": "Assurance", + "finance": "Finances", + "home": "Maison", + "vehicle": "Véhicule", + "legal": "Juridique", + "travel": "Voyage", + "pets": "Animaux", + "warranty": "Garantie", + "taxes": "Impôts", + "work": "Travail", + "other": "Autre" + } } -} \ No newline at end of file +} diff --git a/public/locales/hi.json b/public/locales/hi.json index 7f7bc16..6fd8ecc 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -46,7 +46,8 @@ "navigation": "नेविगेशन", "quickActions": "त्वरित क्रियाएं", "recipes": "रेसिपी", - "more": "और" + "more": "और", + "documents": "दस्तावेज़" }, "dashboard": { "title": "डैशबोर्ड", @@ -897,5 +898,63 @@ }, "emptyHint": { "recipes": "रेसिपी बनाएं और उन्हें अपने भोजन योजनाकार से जोड़ें।" + }, + "documents": { + "title": "दस्तावेज़", + "addButton": "दस्तावेज़ जोड़ें", + "searchPlaceholder": "दस्तावेज़ खोजें...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "सभी श्रेणियाँ", + "emptyTitle": "अभी कोई दस्तावेज़ नहीं", + "emptyDescription": "परिवार के दस्तावेज़ अपलोड करें और तय करें कि हर फ़ाइल कौन देख सकता है।", + "newTitle": "नया दस्तावेज़", + "editTitle": "दस्तावेज़ सेटिंग्स", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "पूरा परिवार", + "restricted": "चुने हुए सदस्य", + "private": "केवल मैं" + }, + "category": { + "medical": "चिकित्सा", + "school": "स्कूल", + "identity": "पहचान", + "insurance": "बीमा", + "finance": "वित्त", + "home": "घर", + "vehicle": "वाहन", + "legal": "कानूनी", + "travel": "यात्रा", + "pets": "पालतू", + "warranty": "वारंटी", + "taxes": "कर", + "work": "काम", + "other": "अन्य" + } } -} \ No newline at end of file +} diff --git a/public/locales/it.json b/public/locales/it.json index 2dff300..76aa5a9 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -46,7 +46,8 @@ "navigation": "Navigazione", "quickActions": "Azioni rapide", "recipes": "Ricette", - "more": "Altro" + "more": "Altro", + "documents": "Documenti" }, "dashboard": { "title": "Panoramica", @@ -897,5 +898,63 @@ }, "emptyHint": { "recipes": "Crea ricette e collegale al tuo piano pasti." + }, + "documents": { + "title": "Documenti", + "addButton": "Aggiungi documento", + "searchPlaceholder": "Cerca documenti...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "Tutte le categorie", + "emptyTitle": "Nessun documento", + "emptyDescription": "Carica documenti di famiglia e controlla chi può vedere ogni file.", + "newTitle": "Nuovo documento", + "editTitle": "Impostazioni documento", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "Tutta la famiglia", + "restricted": "Membri selezionati", + "private": "Solo io" + }, + "category": { + "medical": "Medico", + "school": "Scuola", + "identity": "Identità", + "insurance": "Assicurazione", + "finance": "Finanze", + "home": "Casa", + "vehicle": "Veicolo", + "legal": "Legale", + "travel": "Viaggi", + "pets": "Animali", + "warranty": "Garanzia", + "taxes": "Tasse", + "work": "Lavoro", + "other": "Altro" + } } -} \ No newline at end of file +} diff --git a/public/locales/ja.json b/public/locales/ja.json index c983173..0f952a0 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -46,7 +46,8 @@ "navigation": "ナビゲーション", "quickActions": "クイックアクション", "recipes": "レシピ", - "more": "もっと見る" + "more": "もっと見る", + "documents": "書類" }, "dashboard": { "title": "ダッシュボード", @@ -897,5 +898,63 @@ }, "emptyHint": { "recipes": "レシピを作成して、食事プランに関連付けましょう。" + }, + "documents": { + "title": "書類", + "addButton": "書類を追加", + "searchPlaceholder": "書類を検索...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "すべてのカテゴリ", + "emptyTitle": "書類はまだありません", + "emptyDescription": "家族の書類をアップロードし、各ファイルを見られるメンバーを管理できます。", + "newTitle": "新しい書類", + "editTitle": "書類設定", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "家族全員", + "restricted": "選択したメンバー", + "private": "自分のみ" + }, + "category": { + "medical": "医療", + "school": "学校", + "identity": "本人確認", + "insurance": "保険", + "finance": "金融", + "home": "家", + "vehicle": "車両", + "legal": "法務", + "travel": "旅行", + "pets": "ペット", + "warranty": "保証", + "taxes": "税金", + "work": "仕事", + "other": "その他" + } } -} \ No newline at end of file +} diff --git a/public/locales/pt.json b/public/locales/pt.json index d4db15c..e4c743a 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -46,7 +46,8 @@ "navigation": "Navegação", "quickActions": "Ações rápidas", "recipes": "Receitas", - "more": "Mais" + "more": "Mais", + "documents": "Documentos" }, "dashboard": { "title": "Painel", @@ -903,5 +904,63 @@ }, "emptyHint": { "recipes": "Crie receitas e vincule-as ao seu planejador de refeições." + }, + "documents": { + "title": "Documentos", + "addButton": "Adicionar documento", + "searchPlaceholder": "Buscar documentos...", + "gridView": "Visualizacao em grade", + "listView": "Visualizacao em lista", + "viewToggle": "Visualizacao de documentos", + "allCategories": "Todas as categorias", + "emptyTitle": "Nenhum documento ainda", + "emptyDescription": "Envie documentos da familia e controle quem pode ver cada arquivo.", + "newTitle": "Novo documento", + "editTitle": "Configuracoes do documento", + "nameLabel": "Nome", + "descriptionLabel": "Descricao", + "categoryLabel": "Categoria", + "fileLabel": "Arquivo", + "fileHint": "PDF, imagens, texto e arquivos Office ate 5 MB.", + "visibilityLabel": "Visibilidade", + "statusLabel": "Status", + "allowedMembersLabel": "Membros permitidos", + "uploadAction": "Enviar", + "downloadAction": "Baixar", + "editAction": "Configuracoes", + "archiveAction": "Arquivar", + "restoreAction": "Restaurar", + "savedToast": "Documento salvo.", + "uploadedToast": "Documento enviado.", + "archivedToast": "Documento arquivado.", + "restoredToast": "Documento restaurado.", + "deletedToast": "Documento excluido.", + "deleteConfirm": "Excluir documento \"{{name}}\"?", + "fileRequired": "Selecione um arquivo para enviar.", + "fileTooLarge": "O arquivo pode ter no maximo 5 MB.", + "fileReadError": "Nao foi possivel ler o arquivo.", + "statusActive": "Ativo", + "statusArchived": "Arquivado", + "visibility": { + "family": "Familia inteira", + "restricted": "Membros selecionados", + "private": "Somente eu" + }, + "category": { + "medical": "Medico", + "school": "Escola", + "identity": "Identidade", + "insurance": "Seguro", + "finance": "Financeiro", + "home": "Casa", + "vehicle": "Veiculo", + "legal": "Juridico", + "travel": "Viagem", + "pets": "Pets", + "warranty": "Garantia", + "taxes": "Impostos", + "work": "Trabalho", + "other": "Outros" + } } } diff --git a/public/locales/ru.json b/public/locales/ru.json index 2e5881a..bde9516 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -46,7 +46,8 @@ "navigation": "Навигация", "quickActions": "Быстрые действия", "recipes": "Рецепты", - "more": "Ещё" + "more": "Ещё", + "documents": "Документы" }, "dashboard": { "title": "Обзор", @@ -897,5 +898,63 @@ }, "emptyHint": { "recipes": "Создавайте рецепты и связывайте их с вашим планом питания." + }, + "documents": { + "title": "Документы", + "addButton": "Добавить документ", + "searchPlaceholder": "Поиск документов...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "Все категории", + "emptyTitle": "Документов пока нет", + "emptyDescription": "Загружайте семейные документы и управляйте доступом к каждому файлу.", + "newTitle": "Новый документ", + "editTitle": "Настройки документа", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "Вся семья", + "restricted": "Выбранные участники", + "private": "Только я" + }, + "category": { + "medical": "Медицина", + "school": "Школа", + "identity": "Удостоверения", + "insurance": "Страхование", + "finance": "Финансы", + "home": "Дом", + "vehicle": "Автомобиль", + "legal": "Юридическое", + "travel": "Путешествия", + "pets": "Питомцы", + "warranty": "Гарантия", + "taxes": "Налоги", + "work": "Работа", + "other": "Другое" + } } -} \ No newline at end of file +} diff --git a/public/locales/sv.json b/public/locales/sv.json index 342d0f0..ee548b6 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -46,7 +46,8 @@ "navigation": "Navigering", "quickActions": "Snabba åtgärder", "recipes": "Recept", - "more": "Mer" + "more": "Mer", + "documents": "Dokument" }, "dashboard": { "title": "Översikt", @@ -897,5 +898,63 @@ }, "emptyHint": { "recipes": "Skapa recept och koppla dem till din måltidsplanering." + }, + "documents": { + "title": "Dokument", + "addButton": "Lägg till dokument", + "searchPlaceholder": "Sök dokument...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "Alla kategorier", + "emptyTitle": "Inga dokument ännu", + "emptyDescription": "Ladda upp familjedokument och styr vem som kan se varje fil.", + "newTitle": "Nytt dokument", + "editTitle": "Dokumentinställningar", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "Hela familjen", + "restricted": "Valda medlemmar", + "private": "Endast jag" + }, + "category": { + "medical": "Medicinskt", + "school": "Skola", + "identity": "Identitet", + "insurance": "Försäkring", + "finance": "Ekonomi", + "home": "Hem", + "vehicle": "Fordon", + "legal": "Juridiskt", + "travel": "Resor", + "pets": "Husdjur", + "warranty": "Garanti", + "taxes": "Skatter", + "work": "Arbete", + "other": "Övrigt" + } } -} \ No newline at end of file +} diff --git a/public/locales/tr.json b/public/locales/tr.json index 6088b79..775d548 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -46,7 +46,8 @@ "navigation": "Gezinme", "quickActions": "Hızlı işlemler", "recipes": "Tarifler", - "more": "Daha Fazla" + "more": "Daha Fazla", + "documents": "Belgeler" }, "dashboard": { "title": "Genel Bakış", @@ -897,5 +898,63 @@ }, "emptyHint": { "recipes": "Tarifler oluşturun ve yemek planlayıcınıza bağlayın." + }, + "documents": { + "title": "Belgeler", + "addButton": "Belge ekle", + "searchPlaceholder": "Belgelerde ara...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "Tüm kategoriler", + "emptyTitle": "Henüz belge yok", + "emptyDescription": "Aile belgelerini yükleyin ve her dosyayı kimlerin görebileceğini yönetin.", + "newTitle": "Yeni belge", + "editTitle": "Belge ayarları", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "Tüm aile", + "restricted": "Seçili üyeler", + "private": "Sadece ben" + }, + "category": { + "medical": "Tıbbi", + "school": "Okul", + "identity": "Kimlik", + "insurance": "Sigorta", + "finance": "Finans", + "home": "Ev", + "vehicle": "Araç", + "legal": "Hukuki", + "travel": "Seyahat", + "pets": "Evcil hayvanlar", + "warranty": "Garanti", + "taxes": "Vergiler", + "work": "İş", + "other": "Diğer" + } } -} \ No newline at end of file +} diff --git a/public/locales/uk.json b/public/locales/uk.json index 5333a59..4c100a7 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -46,7 +46,8 @@ "navigation": "Навігація", "quickActions": "Швидкі дії", "recipes": "Рецепти", - "more": "Більше" + "more": "Більше", + "documents": "Документи" }, "dashboard": { "title": "Огляд", @@ -905,5 +906,63 @@ "meals": "Плануйте харчування на тиждень і пов'язуйте рецепти.", "birthdays": "Додайте дні народження — ви отримаєте нагадування завчасно.", "recipes": "Створюйте рецепти та пов'язуйте їх із планувальником харчування." + }, + "documents": { + "title": "Документи", + "addButton": "Додати документ", + "searchPlaceholder": "Пошук документів...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "Усі категорії", + "emptyTitle": "Документів ще немає", + "emptyDescription": "Завантажуйте сімейні документи та керуйте доступом до кожного файлу.", + "newTitle": "Новий документ", + "editTitle": "Налаштування документа", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "Уся сім’я", + "restricted": "Вибрані учасники", + "private": "Лише я" + }, + "category": { + "medical": "Медицина", + "school": "Школа", + "identity": "Посвідчення", + "insurance": "Страхування", + "finance": "Фінанси", + "home": "Дім", + "vehicle": "Авто", + "legal": "Юридичне", + "travel": "Подорожі", + "pets": "Тварини", + "warranty": "Гарантія", + "taxes": "Податки", + "work": "Робота", + "other": "Інше" + } } -} \ No newline at end of file +} diff --git a/public/locales/zh.json b/public/locales/zh.json index cf396f7..77645ce 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -46,7 +46,8 @@ "navigation": "导航", "quickActions": "快捷操作", "recipes": "食谱", - "more": "更多" + "more": "更多", + "documents": "文档" }, "dashboard": { "title": "概览", @@ -897,5 +898,63 @@ }, "emptyHint": { "recipes": "创建食谱并将其关联到你的膳食计划。" + }, + "documents": { + "title": "文档", + "addButton": "添加文档", + "searchPlaceholder": "搜索文档...", + "gridView": "Grid view", + "listView": "List view", + "viewToggle": "Document view", + "allCategories": "所有类别", + "emptyTitle": "还没有文档", + "emptyDescription": "上传家庭文档并控制每个文件的可见成员。", + "newTitle": "新文档", + "editTitle": "文档设置", + "nameLabel": "Name", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "fileLabel": "File", + "fileHint": "PDF, images, text and Office files up to 5 MB.", + "visibilityLabel": "Visibility", + "statusLabel": "Status", + "allowedMembersLabel": "Allowed members", + "uploadAction": "Upload", + "downloadAction": "Download", + "editAction": "Settings", + "archiveAction": "Archive", + "restoreAction": "Restore", + "savedToast": "Document saved.", + "uploadedToast": "Document uploaded.", + "archivedToast": "Document archived.", + "restoredToast": "Document restored.", + "deletedToast": "Document deleted.", + "deleteConfirm": "Delete document \"{{name}}\"?", + "fileRequired": "Select a file to upload.", + "fileTooLarge": "File may be at most 5 MB.", + "fileReadError": "File could not be read.", + "statusActive": "Active", + "statusArchived": "Archived", + "visibility": { + "family": "整个家庭", + "restricted": "选定成员", + "private": "仅我" + }, + "category": { + "medical": "医疗", + "school": "学校", + "identity": "身份", + "insurance": "保险", + "finance": "财务", + "home": "家庭", + "vehicle": "车辆", + "legal": "法律", + "travel": "旅行", + "pets": "宠物", + "warranty": "保修", + "taxes": "税务", + "work": "工作", + "other": "其他" + } } -} \ No newline at end of file +} diff --git a/public/pages/documents.js b/public/pages/documents.js new file mode 100644 index 0000000..464de01 --- /dev/null +++ b/public/pages/documents.js @@ -0,0 +1,377 @@ +/** + * Module: Family Documents + * Purpose: Grid/list document management with local uploads and member visibility. + * Dependencies: /api.js, shared modal, i18n + */ + +import { api } from '/api.js'; +import { openModal as openSharedModal, closeModal } from '/components/modal.js'; +import { t, formatDate } from '/i18n.js'; +import { esc } from '/utils/html.js'; +import { stagger } from '/utils/ux.js'; + +const CATEGORIES = ['medical', 'school', 'identity', 'insurance', 'finance', 'home', 'vehicle', 'legal', 'travel', 'pets', 'warranty', 'taxes', 'work', 'other']; +const MAX_FILE_SIZE = 5 * 1024 * 1024; + +const CATEGORY_ICONS = { + medical: 'heart-pulse', + school: 'graduation-cap', + identity: 'badge-check', + insurance: 'shield-check', + finance: 'landmark', + home: 'home', + vehicle: 'car', + legal: 'scale', + travel: 'plane', + pets: 'paw-print', + warranty: 'receipt', + taxes: 'file-spreadsheet', + work: 'briefcase-business', + other: 'folder', +}; + +function categoryLabels() { + return Object.fromEntries(CATEGORIES.map((category) => [category, t(`documents.category.${category}`)])); +} + +let state = { + documents: [], + members: [], + view: localStorage.getItem('oikos-documents-view') || 'grid', + status: 'active', + category: '', + query: '', +}; +let _container = null; + +export async function render(container) { + _container = container; + container.innerHTML = ` +
+
+

${t('documents.title')}

+ +
+ + +
+ +
+
+ + +
+
+ +
+ `; + + if (window.lucide) lucide.createIcons(); + + await Promise.all([loadMembers(), loadDocuments()]); + bindPageEvents(); + renderDocuments(); +} + +async function loadMembers() { + const res = await api.get('/family/members'); + state.members = res.data || []; +} + +async function loadDocuments() { + const params = new URLSearchParams(); + params.set('status', state.status); + if (state.category) params.set('category', state.category); + const res = await api.get(`/documents?${params.toString()}`); + state.documents = res.data || []; +} + +function bindPageEvents() { + _container.querySelector('#documents-add-btn')?.addEventListener('click', () => openDocumentModal()); + _container.querySelector('#fab-new-document')?.addEventListener('click', () => openDocumentModal()); + _container.querySelector('#documents-search')?.addEventListener('input', (e) => { + state.query = e.target.value.trim().toLowerCase(); + renderDocuments(); + }); + _container.querySelector('#documents-status')?.addEventListener('change', async (e) => { + state.status = e.target.value; + await loadDocuments(); + renderDocuments(); + }); + _container.querySelector('#documents-category')?.addEventListener('change', async (e) => { + state.category = e.target.value; + await loadDocuments(); + renderDocuments(); + }); + _container.querySelector('.documents-view-toggle')?.addEventListener('click', (e) => { + const btn = e.target.closest('[data-view]'); + if (!btn) return; + state.view = btn.dataset.view; + localStorage.setItem('oikos-documents-view', state.view); + _container.querySelectorAll('.documents-view-toggle__btn').forEach((el) => + el.classList.toggle('documents-view-toggle__btn--active', el === btn) + ); + renderDocuments(); + }); + _container.querySelector('#documents-list')?.addEventListener('click', handleDocumentAction); +} + +function filteredDocuments() { + if (!state.query) return state.documents; + return state.documents.filter((doc) => + doc.name.toLowerCase().includes(state.query) || + (doc.description || '').toLowerCase().includes(state.query) || + doc.original_name.toLowerCase().includes(state.query) + ); +} + +function renderDocuments() { + const list = _container.querySelector('#documents-list'); + if (!list) return; + const docs = filteredDocuments(); + list.className = `documents-list documents-list--${state.view}`; + if (!docs.length) { + list.innerHTML = ` +
+ +
${t('documents.emptyTitle')}
+
${t('documents.emptyDescription')}
+
+ `; + if (window.lucide) lucide.createIcons(); + return; + } + list.innerHTML = docs.map((doc) => state.view === 'list' ? renderListItem(doc) : renderGridCard(doc)).join(''); + if (window.lucide) lucide.createIcons(); + stagger(list.querySelectorAll('.document-card, .document-row')); +} + +function renderMeta(doc) { + const labels = categoryLabels(); + return ` + ${labels[doc.category] || doc.category} + ${t(`documents.visibility.${doc.visibility}`)} + ${formatFileSize(doc.file_size)} + `; +} + +function renderActions(doc) { + return ` + + + + + + + `; +} + +function renderGridCard(doc) { + return ` +
+
+
+

${esc(doc.name)}

+

${esc(doc.description || doc.original_name)}

+
${renderMeta(doc)}
+
+ +
+ `; +} + +function renderListItem(doc) { + return ` +
+
+
+

${esc(doc.name)}

+
${renderMeta(doc)}
+
+
${renderActions(doc)}
+
+ `; +} + +async function handleDocumentAction(e) { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + const doc = state.documents.find((item) => String(item.id) === String(btn.dataset.id)); + if (!doc) return; + if (btn.dataset.action === 'edit') openDocumentModal(doc); + if (btn.dataset.action === 'archive') { + await api.patch(`/documents/${doc.id}/archive`, { archived: doc.status !== 'archived' }); + window.oikos?.showToast(doc.status === 'archived' ? t('documents.restoredToast') : t('documents.archivedToast'), 'success'); + await loadDocuments(); + renderDocuments(); + } + if (btn.dataset.action === 'delete') { + if (!confirm(t('documents.deleteConfirm', { name: doc.name }))) return; + await api.delete(`/documents/${doc.id}`); + window.oikos?.showToast(t('documents.deletedToast'), 'success'); + await loadDocuments(); + renderDocuments(); + } +} + +function memberOptions(selected = []) { + const selectedSet = new Set(selected.map(String)); + return state.members.map((member) => ` + + `).join(''); +} + +function openDocumentModal(doc = null) { + const isEdit = !!doc; + openSharedModal({ + title: isEdit ? t('documents.editTitle') : t('documents.newTitle'), + size: 'lg', + content: ` +
+ +
+ + +
+ ${!isEdit ? ` +
+ + +

${t('documents.fileHint')}

+
` : ''} + +
+
${t('documents.allowedMembersLabel')}
+
${memberOptions(doc?.allowed_member_ids || [])}
+
+ + +
+ `, + onSave(panel) { + const form = panel.querySelector('#document-form'); + const visibility = panel.querySelector('#document-visibility'); + const picker = panel.querySelector('#document-member-picker'); + const syncVisibility = () => { picker.hidden = visibility.value !== 'restricted'; }; + visibility.addEventListener('change', syncVisibility); + syncVisibility(); + form.addEventListener('submit', (event) => saveDocument(event, doc)); + }, + }); +} + +async function saveDocument(event, doc) { + event.preventDefault(); + const form = event.target; + const error = form.querySelector('#document-error'); + const submit = form.querySelector('#document-submit'); + error.hidden = true; + submit.disabled = true; + try { + const visibility = form.querySelector('#document-visibility').value; + const payload = { + name: form.querySelector('#document-name').value.trim(), + description: form.querySelector('#document-description').value.trim() || null, + category: form.querySelector('#document-category').value, + visibility, + status: form.querySelector('#document-status').value, + allowed_member_ids: visibility === 'restricted' + ? Array.from(form.querySelectorAll('.document-member-picker input:checked')).map((input) => Number(input.value)) + : [], + }; + if (!doc) { + const file = form.querySelector('#document-file').files?.[0]; + if (!file) throw new Error(t('documents.fileRequired')); + if (file.size > MAX_FILE_SIZE) throw new Error(t('documents.fileTooLarge')); + payload.original_name = file.name; + payload.content_data = await readFileAsDataUrl(file); + if (!payload.name) payload.name = file.name.replace(/\.[^.]+$/, ''); + } + if (!payload.name) throw new Error(t('common.required')); + if (doc) await api.put(`/documents/${doc.id}`, payload); + else await api.post('/documents', payload); + window.oikos?.showToast(doc ? t('documents.savedToast') : t('documents.uploadedToast'), 'success'); + closeModal({ force: true }); + await loadDocuments(); + renderDocuments(); + } catch (err) { + error.textContent = err.message; + error.hidden = false; + } finally { + submit.disabled = false; + } +} + +function readFileAsDataUrl(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(new Error(t('documents.fileReadError'))); + reader.readAsDataURL(file); + }); +} + +function formatFileSize(bytes) { + if (!bytes) return '0 KB'; + if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/public/router.js b/public/router.js index 961d6a3..7a769c0 100644 --- a/public/router.js +++ b/public/router.js @@ -25,6 +25,7 @@ const ROUTES = [ { path: '/recipes', page: '/pages/recipes.js', requiresAuth: true, module: 'recipes' }, { path: '/contacts', page: '/pages/contacts.js', requiresAuth: true, module: 'contacts' }, { path: '/budget', page: '/pages/budget.js', requiresAuth: true, module: 'budget' }, + { path: '/documents', page: '/pages/documents.js', requiresAuth: true, module: 'documents' }, { path: '/settings', page: '/pages/settings.js', requiresAuth: true, module: 'settings' }, ]; @@ -128,7 +129,7 @@ let _pendingLoginRedirect = false; // -------------------------------------------------------- const ROUTE_ORDER = ['/', '/tasks', '/calendar', '/birthdays', '/meals', '/recipes', '/shopping', - '/notes', '/contacts', '/budget', '/settings']; + '/notes', '/contacts', '/budget', '/documents', '/settings']; const PRIMARY_NAV = 4; @@ -181,6 +182,7 @@ function routeTitle(path) { '/notes': t('nav.notes'), '/contacts': t('nav.contacts'), '/budget': t('nav.budget'), + '/documents': t('nav.documents'), '/settings': t('nav.settings'), }; return map[path] || getAppName(); @@ -886,6 +888,7 @@ function navItems() { { path: '/notes', label: t('nav.notes'), icon: 'sticky-note' }, { path: '/contacts', label: t('nav.contacts'), icon: 'book-user' }, { path: '/budget', label: t('nav.budget'), icon: 'wallet' }, + { path: '/documents', label: t('nav.documents'), icon: 'folder-lock' }, { path: '/settings', label: t('nav.settings'), icon: 'settings' }, ]; } diff --git a/public/styles/documents.css b/public/styles/documents.css new file mode 100644 index 0000000..bbcf63d --- /dev/null +++ b/public/styles/documents.css @@ -0,0 +1,279 @@ +/** + * Module: Family Documents + * Purpose: Documents page layout, cards, list rows and upload modal controls. + */ + +.documents-page { + --module-accent: var(--module-documents); + max-width: var(--content-max-width); + margin: 0 auto; +} + +.documents-toolbar { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-top: 3px solid var(--module-accent); + border-bottom: 1px solid var(--color-border); + background-color: var(--color-surface); +} + +.documents-toolbar__title { + font-size: var(--text-lg); + font-weight: var(--font-weight-bold); + flex: 0 0 auto; +} + +.documents-toolbar__search { + position: relative; + flex: 1; + min-width: 180px; +} + +.documents-toolbar__search-icon { + position: absolute; + left: var(--space-3); + top: 50%; + width: 16px; + height: 16px; + transform: translateY(-50%); + color: var(--color-text-tertiary); +} + +.documents-toolbar__search-input { + width: 100%; + min-height: var(--target-base); + padding: 0 var(--space-3) 0 var(--space-9); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface-2); + color: var(--color-text-primary); +} + +.documents-view-toggle { + display: inline-flex; + padding: 2px; + gap: 2px; + border-radius: var(--radius-md); + background: var(--color-surface-2); +} + +.documents-view-toggle__btn { + width: var(--target-base); + height: var(--target-base); + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: calc(var(--radius-md) - 2px); + color: var(--color-text-secondary); +} + +.documents-view-toggle__btn svg { + width: 17px; + height: 17px; +} + +.documents-view-toggle__btn--active { + color: var(--module-accent); + background: var(--color-surface); + box-shadow: var(--shadow-sm); +} + +.documents-filters { + display: flex; + gap: var(--space-3); + padding: var(--space-4); +} + +.documents-filter-select { + max-width: 240px; +} + +.documents-list { + padding: 0 var(--space-4) calc(var(--nav-bottom-height) + var(--space-8)); +} + +.documents-list--grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: var(--space-4); +} + +.documents-list--list { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.document-card, +.document-row { + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + background: var(--color-surface); + box-shadow: var(--shadow-sm); +} + +.document-card { + min-height: 230px; + display: flex; + flex-direction: column; + padding: var(--space-4); +} + +.document-card__icon, +.document-row__icon { + width: 42px; + height: 42px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + color: var(--module-accent); + background: color-mix(in srgb, var(--module-accent) 12%, transparent); +} + +.document-card__icon svg, +.document-row__icon svg { + width: 22px; + height: 22px; +} + +.document-card__body { + flex: 1; + margin-top: var(--space-4); +} + +.document-card__title, +.document-row__title { + margin: 0; + font-size: var(--text-base); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + overflow-wrap: anywhere; +} + +.document-card__description { + min-height: 42px; + margin: var(--space-2) 0 0; + color: var(--color-text-secondary); + font-size: var(--text-sm); + overflow-wrap: anywhere; +} + +.document-card__meta, +.document-row__meta { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-top: var(--space-3); + color: var(--color-text-tertiary); + font-size: var(--text-xs); +} + +.document-card__meta span, +.document-row__meta span { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.document-card__meta svg, +.document-row__meta svg { + width: 13px; + height: 13px; +} + +.document-card__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + margin-top: var(--space-4); + color: var(--color-text-tertiary); + font-size: var(--text-xs); +} + +.document-card__actions, +.document-row__actions { + display: flex; + align-items: center; + gap: var(--space-1); +} + +.document-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); +} + +.document-row__body { + min-width: 0; +} + +.documents-danger { + color: var(--color-danger); +} + +.document-form__hint { + margin-top: var(--space-1); + color: var(--color-text-tertiary); + font-size: var(--text-xs); +} + +.document-member-picker { + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + padding: var(--space-3); + background: var(--color-surface-2); +} + +.document-member-picker__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: var(--space-2); + margin-top: var(--space-2); +} + +.document-member-option { + display: flex; + align-items: center; + gap: var(--space-2); + min-height: var(--target-sm); + color: var(--color-text-secondary); + font-size: var(--text-sm); +} + +@media (max-width: 720px) { + .documents-toolbar { + flex-wrap: wrap; + } + + .documents-toolbar__title { + width: 100%; + } + + .documents-toolbar__search { + order: 4; + flex-basis: 100%; + } + + .documents-filters { + flex-direction: column; + } + + .documents-filter-select { + max-width: none; + } + + .document-row { + grid-template-columns: auto minmax(0, 1fr); + } + + .document-row__actions { + grid-column: 1 / -1; + justify-content: flex-end; + } +} diff --git a/public/styles/tokens.css b/public/styles/tokens.css index 064848f..bd5112a 100644 --- a/public/styles/tokens.css +++ b/public/styles/tokens.css @@ -172,6 +172,8 @@ --module-birthdays: var(--_module-birthdays); /* Rose - Geburtstage */ --_module-budget: #0F766E; --module-budget: var(--_module-budget); /* Teal-700 - Finanzen, Stabilität */ + --_module-documents: #475569; + --module-documents: var(--_module-documents); /* Slate - private family records */ --_module-settings: #6E7781; --module-settings: var(--_module-settings); /* Grau - Konfiguration */ --_module-reminders: #0E7490; diff --git a/public/sw.js b/public/sw.js index 7272c5a..c7e5e5f 100644 --- a/public/sw.js +++ b/public/sw.js @@ -13,10 +13,10 @@ * → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit) */ -const SHELL_CACHE = 'oikos-shell-v66'; -const PAGES_CACHE = 'oikos-pages-v61'; -const LOCALES_CACHE = 'oikos-locales-v10'; -const ASSETS_CACHE = 'oikos-assets-v61'; +const SHELL_CACHE = 'oikos-shell-v67'; +const PAGES_CACHE = 'oikos-pages-v62'; +const LOCALES_CACHE = 'oikos-locales-v11'; +const ASSETS_CACHE = 'oikos-assets-v62'; const BYPASS_CACHE = 'oikos-bypass-flag'; const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, LOCALES_CACHE, ASSETS_CACHE]; @@ -47,6 +47,7 @@ const APP_SHELL = [ '/styles/contacts.css', '/styles/birthdays.css', '/styles/budget.css', + '/styles/documents.css', '/styles/settings.css', '/styles/recipes.css', '/components/oikos-install-prompt.js', @@ -90,6 +91,7 @@ const PAGE_MODULES = [ '/pages/contacts.js', '/pages/birthdays.js', '/pages/budget.js', + '/pages/documents.js', '/pages/settings.js', '/pages/login.js', '/pages/recipes.js', diff --git a/server/db-schema-test.js b/server/db-schema-test.js index 7889e90..880b44b 100644 --- a/server/db-schema-test.js +++ b/server/db-schema-test.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 }; diff --git a/server/db.js b/server/db.js index 30d81a8..85892e0 100644 --- a/server/db.js +++ b/server/db.js @@ -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); + `, + }, ]; /** diff --git a/server/index.js b/server/index.js index 83f3b66..3431711 100644 --- a/server/index.js +++ b/server/index.js @@ -27,6 +27,7 @@ import notesRouter from './routes/notes.js'; import contactsRouter from './routes/contacts.js'; import birthdaysRouter from './routes/birthdays.js'; import budgetRouter from './routes/budget.js'; +import documentsRouter from './routes/documents.js'; import weatherRouter from './routes/weather.js'; import preferencesRouter from './routes/preferences.js'; import remindersRouter from './routes/reminders.js'; @@ -200,6 +201,7 @@ app.use('/api/v1/notes', notesRouter); app.use('/api/v1/contacts', contactsRouter); app.use('/api/v1/birthdays', birthdaysRouter); app.use('/api/v1/budget', budgetRouter); +app.use('/api/v1/documents', documentsRouter); app.use('/api/v1/weather', weatherRouter); app.use('/api/v1/preferences', preferencesRouter); app.use('/api/v1/reminders', remindersRouter); diff --git a/server/routes/documents.js b/server/routes/documents.js new file mode 100644 index 0000000..02a5252 --- /dev/null +++ b/server/routes/documents.js @@ -0,0 +1,262 @@ +/** + * Module: Family Documents + * Purpose: REST API for locally stored family documents with per-member visibility. + * Dependencies: express, server/db.js + */ + +import express from 'express'; +import * as db from '../db.js'; +import { createLogger } from '../logger.js'; +import { str, collectErrors, MAX_TEXT, MAX_TITLE } from '../middleware/validate.js'; + +const log = createLogger('Documents'); +const router = express.Router(); + +const CATEGORIES = ['medical', 'school', 'identity', 'insurance', 'finance', 'home', 'vehicle', 'legal', 'travel', 'pets', 'warranty', 'taxes', 'work', 'other']; +const VISIBILITIES = ['family', 'restricted', 'private']; +const STATUSES = ['active', 'archived']; +const MAX_FILE_BYTES = 5 * 1024 * 1024; +const ALLOWED_MIME = new Set([ + 'application/pdf', + 'image/png', + 'image/jpeg', + 'image/webp', + 'text/plain', + 'text/csv', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', +]); + +function userId(req) { + return req.authUserId || req.session.userId; +} + +function isAdmin(req) { + return req.authRole === 'admin' || req.session?.role === 'admin'; +} + +function canSeeSql(alias = 'd') { + return `( + ${alias}.created_by = @userId + OR ${alias}.visibility = 'family' + OR EXISTS ( + SELECT 1 FROM family_document_access a + WHERE a.document_id = ${alias}.id AND a.user_id = @userId + ) + )`; +} + +function parseMemberIds(value) { + if (!Array.isArray(value)) return []; + return [...new Set(value.map((id) => Number(id)).filter((id) => Number.isInteger(id) && id > 0))]; +} + +function parseDataUrl(dataUrl) { + const raw = String(dataUrl || ''); + const match = raw.match(/^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/); + if (!match) return { error: 'File content must be a valid base64 data URL.' }; + const mime = match[1].toLowerCase(); + if (!ALLOWED_MIME.has(mime)) return { error: 'File type is not allowed.' }; + const base64 = match[2].replace(/\s/g, ''); + const buffer = Buffer.from(base64, 'base64'); + if (!buffer.length) return { error: 'File content is empty.' }; + if (buffer.length > MAX_FILE_BYTES) return { error: 'File may be at most 5 MB.' }; + return { mime, base64, size: buffer.length, buffer }; +} + +function documentSelect() { + return ` + SELECT d.id, d.name, d.description, d.category, d.status, d.visibility, + d.original_name, d.mime_type, d.file_size, d.storage_provider, + d.storage_key, d.created_by, d.created_at, d.updated_at, + u.display_name AS creator_name, u.avatar_color AS creator_color, + GROUP_CONCAT(a.user_id) AS allowed_member_ids + FROM family_documents d + LEFT JOIN users u ON u.id = d.created_by + LEFT JOIN family_document_access a ON a.document_id = d.id + `; +} + +function normalizeDocument(row) { + if (!row) return null; + return { + ...row, + allowed_member_ids: row.allowed_member_ids + ? row.allowed_member_ids.split(',').map((id) => Number(id)).filter(Boolean) + : [], + }; +} + +function getVisibleDocument(id, req, includeContent = false) { + const columns = includeContent ? 'd.*' : 'd.id, d.created_by, d.visibility, d.description'; + return db.get().prepare(` + SELECT ${columns} + FROM family_documents d + WHERE d.id = @id AND ${canSeeSql('d')} + `).get({ id, userId: userId(req) }); +} + +function replaceAccess(documentId, memberIds) { + const database = db.get(); + database.prepare('DELETE FROM family_document_access WHERE document_id = ?').run(documentId); + const insert = database.prepare('INSERT OR IGNORE INTO family_document_access (document_id, user_id) VALUES (?, ?)'); + for (const memberId of memberIds) insert.run(documentId, memberId); +} + +router.get('/meta/options', (_req, res) => { + res.json({ + data: { + categories: CATEGORIES, + visibilities: VISIBILITIES, + statuses: STATUSES, + max_file_size: MAX_FILE_BYTES, + allowed_mime_types: Array.from(ALLOWED_MIME), + storage_providers: ['local'], + }, + }); +}); + +router.get('/', (req, res) => { + try { + const status = STATUSES.includes(req.query.status) ? req.query.status : 'active'; + const category = CATEGORIES.includes(req.query.category) ? req.query.category : null; + const params = { userId: userId(req), status, category }; + const rows = db.get().prepare(` + ${documentSelect()} + WHERE ${canSeeSql('d')} + AND d.status = @status + AND (@category IS NULL OR d.category = @category) + GROUP BY d.id + ORDER BY d.updated_at DESC + `).all(params); + res.json({ data: rows.map(normalizeDocument) }); + } catch (err) { + log.error('GET / error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.post('/', (req, res) => { + try { + const vName = str(req.body.name, 'Name', { max: MAX_TITLE }); + const vDescription = str(req.body.description, 'Description', { max: MAX_TEXT, required: false }); + const vOriginalName = str(req.body.original_name, 'Original filename', { max: MAX_TITLE }); + const errors = collectErrors([vName, vDescription, vOriginalName]); + if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); + + const category = CATEGORIES.includes(req.body.category) ? req.body.category : 'other'; + const visibility = VISIBILITIES.includes(req.body.visibility) ? req.body.visibility : 'family'; + const parsed = parseDataUrl(req.body.content_data); + if (parsed.error) return res.status(400).json({ error: parsed.error, code: 400 }); + + const allowedIds = visibility === 'restricted' ? parseMemberIds(req.body.allowed_member_ids) : []; + const database = db.get(); + const result = database.prepare(` + INSERT INTO family_documents + (name, description, category, visibility, original_name, mime_type, file_size, content_data, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(vName.value, vDescription.value, category, visibility, vOriginalName.value, parsed.mime, parsed.size, parsed.base64, userId(req)); + if (visibility === 'restricted') replaceAccess(result.lastInsertRowid, allowedIds); + + const row = database.prepare(` + ${documentSelect()} + WHERE d.id = ? + GROUP BY d.id + `).get(result.lastInsertRowid); + res.status(201).json({ data: normalizeDocument(row) }); + } catch (err) { + log.error('POST / error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.put('/:id', (req, res) => { + try { + const id = Number(req.params.id); + const existing = getVisibleDocument(id, req); + if (!existing) return res.status(404).json({ error: 'Document not found.', code: 404 }); + if (existing.created_by !== userId(req) && !isAdmin(req)) return res.status(403).json({ error: 'Not authorized.', code: 403 }); + + const vName = req.body.name !== undefined ? str(req.body.name, 'Name', { max: MAX_TITLE }) : { value: null }; + const vDescription = req.body.description !== undefined ? str(req.body.description, 'Description', { max: MAX_TEXT, required: false }) : { value: null }; + const errors = collectErrors([vName, vDescription]); + if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); + + const category = req.body.category !== undefined && CATEGORIES.includes(req.body.category) ? req.body.category : null; + const visibility = req.body.visibility !== undefined && VISIBILITIES.includes(req.body.visibility) ? req.body.visibility : null; + const status = req.body.status !== undefined && STATUSES.includes(req.body.status) ? req.body.status : null; + db.get().prepare(` + UPDATE family_documents + SET name = COALESCE(?, name), + description = ?, + category = COALESCE(?, category), + visibility = COALESCE(?, visibility), + status = COALESCE(?, status) + WHERE id = ? + `).run( + req.body.name !== undefined ? vName.value : null, + req.body.description !== undefined ? vDescription.value : existing.description, + category, + visibility, + status, + id + ); + if ((visibility || existing.visibility) === 'restricted') replaceAccess(id, parseMemberIds(req.body.allowed_member_ids)); + else replaceAccess(id, []); + + const row = db.get().prepare(`${documentSelect()} WHERE d.id = ? GROUP BY d.id`).get(id); + res.json({ data: normalizeDocument(row) }); + } catch (err) { + log.error('PUT /:id error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.patch('/:id/archive', (req, res) => { + try { + const id = Number(req.params.id); + const existing = getVisibleDocument(id, req); + if (!existing) return res.status(404).json({ error: 'Document not found.', code: 404 }); + if (existing.created_by !== userId(req) && !isAdmin(req)) return res.status(403).json({ error: 'Not authorized.', code: 403 }); + const status = req.body.archived === false ? 'active' : 'archived'; + db.get().prepare('UPDATE family_documents SET status = ? WHERE id = ?').run(status, id); + res.json({ data: { id, status } }); + } catch (err) { + log.error('PATCH /:id/archive error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.get('/:id/download', (req, res) => { + try { + const id = Number(req.params.id); + const doc = getVisibleDocument(id, req, true); + if (!doc) return res.status(404).json({ error: 'Document not found.', code: 404 }); + const filename = encodeURIComponent(doc.original_name.replace(/[/\\]/g, '_')); + res.setHeader('Content-Type', doc.mime_type); + res.setHeader('Content-Length', String(doc.file_size)); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.end(Buffer.from(doc.content_data, 'base64')); + } catch (err) { + log.error('GET /:id/download error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.delete('/:id', (req, res) => { + try { + const id = Number(req.params.id); + const existing = getVisibleDocument(id, req); + if (!existing) return res.status(404).json({ error: 'Document not found.', code: 404 }); + if (existing.created_by !== userId(req) && !isAdmin(req)) return res.status(403).json({ error: 'Not authorized.', code: 403 }); + db.get().prepare('DELETE FROM family_documents WHERE id = ?').run(id); + res.status(204).end(); + } catch (err) { + log.error('DELETE /:id error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +export default router; From 1ca8110d56c24a45f3a243830c8791f6bd84b944 Mon Sep 17 00:00:00 2001 From: Rafael Foster Date: Wed, 29 Apr 2026 06:27:37 -0300 Subject: [PATCH 7/7] fix(documents): improve upload modal and document theme --- public/components/modal.js | 20 +++++++++- public/locales/ar.json | 5 ++- public/locales/de.json | 5 ++- public/locales/el.json | 5 ++- public/locales/en.json | 5 ++- public/locales/es.json | 5 ++- public/locales/fr.json | 5 ++- public/locales/hi.json | 5 ++- public/locales/it.json | 5 ++- public/locales/ja.json | 5 ++- public/locales/pt.json | 5 ++- public/locales/ru.json | 5 ++- public/locales/sv.json | 5 ++- public/locales/tr.json | 5 ++- public/locales/uk.json | 5 ++- public/locales/zh.json | 5 ++- public/pages/documents.js | 46 ++++++++++++++++++++++- public/styles/documents.css | 75 +++++++++++++++++++++++++++++++++++-- public/styles/tokens.css | 4 +- public/sw.js | 8 ++-- 20 files changed, 200 insertions(+), 28 deletions(-) diff --git a/public/components/modal.js b/public/components/modal.js index 4dcb51f..c124191 100644 --- a/public/components/modal.js +++ b/public/components/modal.js @@ -105,7 +105,7 @@ function trapFocus(container) { // -------------------------------------------------------- function serializeForm(container) { - const inputs = container.querySelectorAll('input, select, textarea'); + const inputs = container.querySelectorAll('input:not([type="file"]), select, textarea'); return Array.from(inputs).map((el) => `${el.name || el.id}=${el.value}`).join('&'); } @@ -327,17 +327,33 @@ export async function closeModal({ force = false } = {}) { if (!force) { const panel = activeOverlay.querySelector('.modal-panel'); if (panel && isFormDirty(panel)) { + const dirtyOverlay = activeOverlay; + const dirtySnapshot = _initialFormSnapshot; let confirmed; try { + activeOverlay = null; + _isClosing = false; confirmed = await confirmModal(t('modal.unsavedChanges'), { danger: false, confirmLabel: t('modal.discardChanges'), }); } catch (err) { + activeOverlay = dirtyOverlay; + _initialFormSnapshot = dirtySnapshot; _isClosing = false; throw err; } - if (!confirmed) { _isClosing = false; return; } + activeOverlay = dirtyOverlay; + _initialFormSnapshot = dirtySnapshot; + if (!confirmed) { + document.body.style.overflow = 'hidden'; + if (window.oikos?.setThemeColor) { + window.oikos.setThemeColor(OVERLAY_THEME_COLOR, OVERLAY_THEME_COLOR); + } + _isClosing = false; + return; + } + _isClosing = true; } } diff --git a/public/locales/ar.json b/public/locales/ar.json index 25ac6a0..30cbf0c 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -955,6 +955,9 @@ "taxes": "ضرائب", "work": "عمل", "other": "أخرى" - } + }, + "dropzoneTitle": "أفلت الملف هنا أو انقر للاختيار", + "dropzoneHint": "اسحب ملفًا إلى هذه المنطقة أو استخدم محدد الملفات.", + "selectedFileLabel": "المحدد: {{name}}" } } diff --git a/public/locales/de.json b/public/locales/de.json index 01cb775..b60674f 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -993,6 +993,9 @@ "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}}" } } diff --git a/public/locales/el.json b/public/locales/el.json index 434d361..27e250f 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -955,6 +955,9 @@ "taxes": "Φόροι", "work": "Εργασία", "other": "Άλλο" - } + }, + "dropzoneTitle": "Αφήστε το αρχείο εδώ ή κάντε κλικ για επιλογή", + "dropzoneHint": "Σύρετε ένα αρχείο σε αυτήν την περιοχή ή χρησιμοποιήστε τον επιλογέα αρχείων.", + "selectedFileLabel": "Επιλέχθηκε: {{name}}" } } diff --git a/public/locales/en.json b/public/locales/en.json index de7002a..77a4869 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -979,6 +979,9 @@ "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}}" } } diff --git a/public/locales/es.json b/public/locales/es.json index b75d023..63f2403 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -955,6 +955,9 @@ "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}}" } } diff --git a/public/locales/fr.json b/public/locales/fr.json index 552f271..5f2d9bc 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -955,6 +955,9 @@ "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}}" } } diff --git a/public/locales/hi.json b/public/locales/hi.json index 6fd8ecc..0140a78 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -955,6 +955,9 @@ "taxes": "कर", "work": "काम", "other": "अन्य" - } + }, + "dropzoneTitle": "फ़ाइल यहाँ छोड़ें या चुनने के लिए क्लिक करें", + "dropzoneHint": "फ़ाइल को इस क्षेत्र में खींचें या फ़ाइल पिकर का उपयोग करें।", + "selectedFileLabel": "चयनित: {{name}}" } } diff --git a/public/locales/it.json b/public/locales/it.json index 76aa5a9..a186987 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -955,6 +955,9 @@ "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}}" } } diff --git a/public/locales/ja.json b/public/locales/ja.json index 0f952a0..af14261 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -955,6 +955,9 @@ "taxes": "税金", "work": "仕事", "other": "その他" - } + }, + "dropzoneTitle": "ここにファイルをドロップ、またはクリックして選択", + "dropzoneHint": "この領域にファイルをドラッグするか、ファイル選択を使用します。", + "selectedFileLabel": "選択済み: {{name}}" } } diff --git a/public/locales/pt.json b/public/locales/pt.json index e4c743a..cc192bc 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -961,6 +961,9 @@ "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}}" } } diff --git a/public/locales/ru.json b/public/locales/ru.json index bde9516..cbde67e 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -955,6 +955,9 @@ "taxes": "Налоги", "work": "Работа", "other": "Другое" - } + }, + "dropzoneTitle": "Перетащите файл сюда или нажмите для выбора", + "dropzoneHint": "Перетащите файл в эту область или используйте выбор файла.", + "selectedFileLabel": "Выбрано: {{name}}" } } diff --git a/public/locales/sv.json b/public/locales/sv.json index ee548b6..22ccc90 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -955,6 +955,9 @@ "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}}" } } diff --git a/public/locales/tr.json b/public/locales/tr.json index 775d548..9129103 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -955,6 +955,9 @@ "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}}" } } diff --git a/public/locales/uk.json b/public/locales/uk.json index 4c100a7..e0c66de 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -963,6 +963,9 @@ "taxes": "Податки", "work": "Робота", "other": "Інше" - } + }, + "dropzoneTitle": "Перетягніть файл сюди або натисніть для вибору", + "dropzoneHint": "Перетягніть файл у цю область або скористайтеся вибором файлу.", + "selectedFileLabel": "Вибрано: {{name}}" } } diff --git a/public/locales/zh.json b/public/locales/zh.json index 77645ce..730b037 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -955,6 +955,9 @@ "taxes": "税务", "work": "工作", "other": "其他" - } + }, + "dropzoneTitle": "将文件拖到此处或点击选择", + "dropzoneHint": "将文件拖入此区域,或使用文件选择器。", + "selectedFileLabel": "已选择:{{name}}" } } diff --git a/public/pages/documents.js b/public/pages/documents.js index 464de01..57cf30d 100644 --- a/public/pages/documents.js +++ b/public/pages/documents.js @@ -277,7 +277,15 @@ function openDocumentModal(doc = null) { ${!isEdit ? `
- +

${t('documents.fileHint')}

` : ''}