diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b7638f..bbd9291 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.20.41] - 2026-04-21 + +### Fixed +- Race condition in `router.js`: when `auth.me()` failed during initial navigation, `_pendingLoginRedirect` was not cleared before calling `navigate('/login')` from the catch block, causing the `finally` handler to launch a second concurrent navigation. If the second navigation was still in progress when the user submitted the login form, `navigate('/', user)` was silently blocked — login appeared to succeed but the dashboard never loaded (most noticeable on iOS Safari PWA with iCloud Keychain autofill) + +### Added +- Version number displayed on the login page (fetched from new `GET /api/v1/version` endpoint, no auth required), so users can verify which release their PWA is running + ## [0.20.40] - 2026-04-21 ### Changed diff --git a/package-lock.json b/package-lock.json index fc99b70..7753f01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oikos", - "version": "0.20.40", + "version": "0.20.41", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oikos", - "version": "0.20.40", + "version": "0.20.41", "license": "MIT", "dependencies": { "bcrypt": "^6.0.0", diff --git a/package.json b/package.json index da978eb..e9b1b4e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.20.40", + "version": "0.20.41", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.", "main": "server/index.js", "type": "module", diff --git a/public/locales/ar.json b/public/locales/ar.json index 6d9b6db..c4a3a40 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -567,7 +567,8 @@ "loginButton": "تسجيل الدخول", "loggingIn": "جارٍ تسجيل الدخول…", "tooManyAttempts": "محاولات كثيرة جداً. يرجى الانتظار قليلاً.", - "invalidCredentials": "بيانات اعتماد غير صالحة." + "invalidCredentials": "بيانات اعتماد غير صالحة.", + "version": "v{{version}}" }, "install": { "title": "تثبيت Oikos", @@ -604,4 +605,4 @@ "unitMonth": "شهر", "unitMonths": "أشهر" } -} \ No newline at end of file +} diff --git a/public/locales/de.json b/public/locales/de.json index f06802d..74f0e09 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -618,7 +618,8 @@ "loginButton": "Anmelden", "loggingIn": "Wird angemeldet …", "tooManyAttempts": "Zu viele Versuche. Bitte warte kurz.", - "invalidCredentials": "Ungültige Anmeldedaten." + "invalidCredentials": "Ungültige Anmeldedaten.", + "version": "v{{version}}" }, "install": { "title": "Oikos installieren", @@ -676,4 +677,4 @@ "pendingBadgeTitle": "{{count}} fällige Erinnerung", "pendingBadgeTitlePlural": "{{count}} fällige Erinnerungen" } -} \ No newline at end of file +} diff --git a/public/locales/el.json b/public/locales/el.json index e65bb5f..ffff781 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -567,7 +567,8 @@ "loginButton": "Σύνδεση", "loggingIn": "Σύνδεση…", "tooManyAttempts": "Πολλές προσπάθειες. Παρακαλώ περιμένετε λίγο.", - "invalidCredentials": "Λανθασμένα στοιχεία σύνδεσης." + "invalidCredentials": "Λανθασμένα στοιχεία σύνδεσης.", + "version": "v{{version}}" }, "install": { "title": "Εγκατάσταση Oikos", @@ -604,4 +605,4 @@ "unitMonth": "μήνα", "unitMonths": "μήνες" } -} \ No newline at end of file +} diff --git a/public/locales/en.json b/public/locales/en.json index 6dc98a2..873e2f1 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -567,7 +567,8 @@ "loginButton": "Log in", "loggingIn": "Logging in…", "tooManyAttempts": "Too many attempts. Please wait a moment.", - "invalidCredentials": "Invalid credentials." + "invalidCredentials": "Invalid credentials.", + "version": "v{{version}}" }, "install": { "title": "Install Oikos", @@ -625,4 +626,4 @@ "pendingBadgeTitle": "{{count}} reminder due", "pendingBadgeTitlePlural": "{{count}} reminders due" } -} \ No newline at end of file +} diff --git a/public/locales/es.json b/public/locales/es.json index 58fbfd9..2149472 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -567,7 +567,8 @@ "loginButton": "Iniciar sesión", "loggingIn": "Iniciando sesión…", "tooManyAttempts": "Demasiados intentos. Por favor, espera un momento.", - "invalidCredentials": "Credenciales incorrectas." + "invalidCredentials": "Credenciales incorrectas.", + "version": "v{{version}}" }, "install": { "title": "Instalar Oikos", @@ -604,4 +605,4 @@ "unitMonth": "mes", "unitMonths": "meses" } -} \ No newline at end of file +} diff --git a/public/locales/fr.json b/public/locales/fr.json index be973cc..3b606ca 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -567,7 +567,8 @@ "loginButton": "Se connecter", "loggingIn": "Connexion…", "tooManyAttempts": "Trop de tentatives. Veuillez patienter un moment.", - "invalidCredentials": "Identifiants invalides." + "invalidCredentials": "Identifiants invalides.", + "version": "v{{version}}" }, "install": { "title": "Installer Oikos", @@ -604,4 +605,4 @@ "unitMonth": "mois", "unitMonths": "mois" } -} \ No newline at end of file +} diff --git a/public/locales/hi.json b/public/locales/hi.json index 11886e5..6e083ad 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -567,7 +567,8 @@ "loginButton": "लॉग इन", "loggingIn": "लॉग इन हो रहा है…", "tooManyAttempts": "बहुत अधिक प्रयास। कृपया थोड़ा प्रतीक्षा करें।", - "invalidCredentials": "अमान्य क्रेडेंशियल।" + "invalidCredentials": "अमान्य क्रेडेंशियल।", + "version": "v{{version}}" }, "install": { "title": "Oikos इंस्टॉल करें", @@ -604,4 +605,4 @@ "unitMonth": "माह", "unitMonths": "माह" } -} \ No newline at end of file +} diff --git a/public/locales/it.json b/public/locales/it.json index b8f5fc6..49869a2 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -567,7 +567,8 @@ "loginButton": "Accedi", "loggingIn": "Accesso in corso…", "tooManyAttempts": "Troppi tentativi. Attendi un momento.", - "invalidCredentials": "Credenziali non valide." + "invalidCredentials": "Credenziali non valide.", + "version": "v{{version}}" }, "install": { "title": "Installa Oikos", @@ -604,4 +605,4 @@ "unitMonth": "mese", "unitMonths": "mesi" } -} \ No newline at end of file +} diff --git a/public/locales/ja.json b/public/locales/ja.json index 092bd8a..93c600f 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -567,7 +567,8 @@ "loginButton": "ログイン", "loggingIn": "ログイン中…", "tooManyAttempts": "試行回数が多すぎます。しばらくお待ちください。", - "invalidCredentials": "ユーザー名またはパスワードが正しくありません。" + "invalidCredentials": "ユーザー名またはパスワードが正しくありません。", + "version": "v{{version}}" }, "install": { "title": "Oikos をインストール", @@ -604,4 +605,4 @@ "unitMonth": "ヶ月", "unitMonths": "ヶ月" } -} \ No newline at end of file +} diff --git a/public/locales/pt.json b/public/locales/pt.json index 9bbe678..3ed43e9 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -567,7 +567,8 @@ "loginButton": "Entrar", "loggingIn": "Entrando…", "tooManyAttempts": "Muitas tentativas. Por favor, aguarde.", - "invalidCredentials": "Credenciais inválidas." + "invalidCredentials": "Credenciais inválidas.", + "version": "v{{version}}" }, "install": { "title": "Instalar Oikos", @@ -604,4 +605,4 @@ "unitMonth": "mês", "unitMonths": "meses" } -} \ No newline at end of file +} diff --git a/public/locales/ru.json b/public/locales/ru.json index 0e8f8a8..519ccc6 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -567,7 +567,8 @@ "loginButton": "Войти", "loggingIn": "Вход…", "tooManyAttempts": "Слишком много попыток. Подождите немного.", - "invalidCredentials": "Неверные данные для входа." + "invalidCredentials": "Неверные данные для входа.", + "version": "v{{version}}" }, "install": { "title": "Установить Oikos", @@ -604,4 +605,4 @@ "unitMonth": "месяц", "unitMonths": "месяцев" } -} \ No newline at end of file +} diff --git a/public/locales/sv.json b/public/locales/sv.json index 11a34b6..1cb208b 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -567,7 +567,8 @@ "loginButton": "Logga in", "loggingIn": "Loggar in...", "tooManyAttempts": "För många försök. Vänta ett ögonblick.", - "invalidCredentials": "Ogiltiga användaruppgifter." + "invalidCredentials": "Ogiltiga användaruppgifter.", + "version": "v{{version}}" }, "install": { "title": "Installera Oikos", @@ -604,4 +605,4 @@ "unitMonth": "månad", "unitMonths": "månader" } -} \ No newline at end of file +} diff --git a/public/locales/tr.json b/public/locales/tr.json index 080347e..fc81daf 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -567,7 +567,8 @@ "loginButton": "Giriş yap", "loggingIn": "Giriş yapılıyor…", "tooManyAttempts": "Çok fazla deneme. Lütfen bir süre bekleyin.", - "invalidCredentials": "Geçersiz kimlik bilgileri." + "invalidCredentials": "Geçersiz kimlik bilgileri.", + "version": "v{{version}}" }, "install": { "title": "Oikos'u Yükle", @@ -604,4 +605,4 @@ "unitMonth": "ay", "unitMonths": "ay" } -} \ No newline at end of file +} diff --git a/public/locales/uk.json b/public/locales/uk.json index 6a0361f..d87d355 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -1,628 +1,629 @@ { - "common": { - "save": "Зберегти", - "cancel": "Скасувати", - "delete": "Видалити", - "edit": "Редагувати", - "close": "Закрити", - "create": "Створити", - "add": "Додати", - "back": "Назад", - "next": "Далі", - "loading": "Завантаження…", - "saving": "Збереження…", - "required": "Це поле є обов'язковим.", - "error": "Помилка", - "allFieldsRequired": "Будь ласка, заповніть усі поля.", - "today": "Сьогодні", - "tomorrow": "Завтра", - "skipToContent": "Перейти до вмісту", - "reload": "Оновити", - "errorOccurred": "Щось пішло не так.", - "unexpectedError": "Сталася непередбачена помилка.", - "errorGeneric": "Сталася помилка.", - "updateAvailable": "Доступне оновлення — перезавантажте сторінку, щоб отримати останню версію.", - "titleRequired": "Заголовок є обов'язковим", - "nameRequired": "Ім'я є обов'язковим", - "contentRequired": "Вміст є обов'язковим", - "all": "Усі", - "unknownError": "Невідома помилка", - "confirm": "Підтвердити", - "undo": "Скасувати" - }, - "nav": { - "dashboard": "Огляд", - "tasks": "Завдання", - "calendar": "Календар", - "meals": "Харчування", - "shopping": "Покупки", - "notes": "Нотатки", - "contacts": "Контакти", - "budget": "Бюджет", - "settings": "Налаштування", - "main": "Головна навігація", - "navigation": "Навігація", - "quickActions": "Швидкі дії" - }, - "dashboard": { - "title": "Огляд", - "greetingMorning": "Доброго ранку, {{name}}", - "greetingDay": "Доброго дня, {{name}}", - "greetingEvening": "Доброго вечора, {{name}}", - "allDone": "Усе зроблено", - "noEvents": "Немає подій", - "noPinnedNotes": "Немає закріплених нотаток", - "todayMeals": "Страви на сьогодні", - "allLink": "Усі", - "weekLink": "Тиждень", - "urgentTasksChip": "{{count}} термінове завдання", - "urgentTasksChipPlural": "{{count}} термінових завдань", - "eventsChip": "{{count}} подія сьогодні", - "eventsChipPlural": "{{count}} подій сьогодні", - "todayMealChip": "Сьогодні: {{title}}", - "loadError": "Огляд не вдалося повністю завантажити.", - "weatherRefresh": "Оновити погоду", - "weatherRefreshTitle": "Оновити", - "weatherUpdated": "Погоду оновлено", - "weatherFeelsLike": "Відчувається як {{temp}}° · {{humidity}}% · Вітер {{wind}} км/год", - "fabTaskLabel": "Додати завдання", - "fabCalendarLabel": "Додати подію", - "fabShoppingLabel": "Додати покупку", - "fabNoteLabel": "Додати нотатку", - "fabTask": "Завдання", - "fabCalendar": "Подія", - "fabShopping": "Покупка", - "fabNote": "Нотатка", - "overdue": "Прострочено", - "dueSoon": "Сьогодні", - "dueTomorrow": "Завтра", - "allDay": "Весь день", - "shoppingMore": "+{{count}} ще", - "weather": "Погода", - "customize": "Налаштувати", - "customizeTitle": "Налаштувати віджети", - "customizeReset": "Скинути", - "customizeSaved": "Огляд збережено", - "customizeMoveUp": "Перемістити вгору", - "customizeMoveDown": "Перемістити вниз" - }, - "tasks": { - "title": "Завдання", - "newTask": "Нове завдання", - "editTask": "Редагувати завдання", - "emptyTitle": "Завдань немає — все зроблено?", - "emptyDescription": "Створіть нові завдання кнопкою +.", - "titleLabel": "Заголовок *", - "titlePlaceholder": "Що потрібно зробити?", - "descriptionLabel": "Нотатка", - "descriptionPlaceholder": "Необов'язкові деталі…", - "priorityLabel": "Пріоритет", - "categoryLabel": "Категорія", - "dueDateLabel": "Термін виконання", - "dueTimeLabel": "Час", - "assignedLabel": "Призначено", - "assignedNobody": "- Нікому -", - "statusLabel": "Статус", - "priorityUrgent": "Терміново", - "priorityHigh": "Високий", - "priorityMedium": "Середній", - "priorityLow": "Низький", - "priorityNone": "Без пріоритету", - "statusOpen": "Відкрито", - "statusInProgress": "В процесі", - "statusDone": "Виконано", - "categoryHousehold": "Побут", - "categorySchool": "Навчання", - "categoryShopping": "Покупки", - "categoryRepair": "Ремонт", - "categoryHealth": "Здоров'я", - "categoryFinance": "Фінанси", - "categoryLeisure": "Дозвілля", - "categoryMisc": "Різне", - "overdue": "Прострочено", - "overdueDay": "Прострочено на {{count}} д.", - "dueToday": "Сьогодні", - "dueTomorrow": "Завтра", - "groupOverdue": "Прострочено", - "groupToday": "Сьогодні", - "groupThisWeek": "Цього тижня", - "groupNextWeek": "Наступного тижня", - "groupLater": "Пізніше", - "groupNoDate": "Без дати", - "markDone": "Позначити {{title}} як виконане", - "markOpen": "Позначити {{title}} як відкрите", - "editButton": "Редагувати завдання", - "swipeOpen": "Відкрити знову", - "swipeDone": "Виконано", - "swipeEdit": "Редагувати", - "subtaskAdd": "+ Додати підзавдання", - "subtaskToggle": "Показати підзавдання", - "subtaskMarkDone": "Позначити {{title}} як виконане", - "deleteConfirm": "Видалити завдання та всі підзавдання?", - "savedToast": "Завдання збережено.", - "createdToast": "Завдання створено.", - "deletedToast": "Завдання видалено.", - "loadError": "Не вдалося завантажити завдання.", - "subtaskPrompt": "Підзавдання:", - "kanbanOpen": "Відкрито", - "kanbanInProgress": "В процесі", - "kanbanDone": "Виконано", - "kanbanMoveToInProgress": "Позначити як «в процесі»", - "kanbanMoveToDone": "Позначити як виконане", - "kanbanMoveToOpen": "Відкрити знову", - "recurring": "Повторюване", - "listView": "Список", - "kanbanView": "Канбан" - }, - "shopping": { - "title": "Покупки", - "noLists": "Немає списків", - "noListsDescription": "Створіть список кнопкою +.", - "emptyList": "Список порожній", - "emptyListDescription": "Додайте товари через поле вводу вище.", - "newListPrompt": "Назва нового списку:", - "newListButton": "Створити новий список", - "renameListPrompt": "Нова назва списку:", - "deleteListConfirm": "Видалити список «{{name}}» та всі товари?", - "deletedListToast": "Список видалено.", - "itemDeletedToast": "«{{name}}» видалено.", - "itemsRemovedToast": "{{count}} товарів видалено.", - "clearChecked": "Видалити відмічені ({{count}})", - "itemNamePlaceholder": "Додати товар…", - "itemQtyPlaceholder": "Кількість", - "itemNameLabel": "Назва товару", - "itemQtyLabel": "Кількість", - "categoryLabel": "Категорія", - "addItemLabel": "Додати товар", - "renameListLabel": "Перейменувати список", - "deleteListLabel": "Видалити список", - "swipeBack": "Скасувати", - "swipeCheck": "Відмітити", - "swipeDelete": "Видалити", - "markDoneLabel": "Відмітити {{name}}", - "markUndoneLabel": "Зняти відмітку з {{name}}", - "deleteItemLabel": "Видалити {{name}}", - "listsLoadError": "Не вдалося завантажити списки.", - "itemsLoadError": "Не вдалося завантажити товари.", - "catFruitVeg": "Фрукти та овочі", - "catBakery": "Випічка", - "catDairy": "Молочні продукти", - "catMeatFish": "М'ясо та риба", - "catFrozen": "Заморожені продукти", - "catDrinks": "Напої", - "catHousehold": "Господарські товари", - "catDrugstore": "Аптека", - "catMisc": "Різне" - }, - "meals": { - "title": "План харчування", - "noMealPlanned": "Страву не заплановано", - "addMeal": "Додати {{type}}", - "editMeal": "Редагувати страву", - "addMealTitle": "Додати страву", - "deleteMeal": "Видалити страву", - "transferToShoppingList": "Додати інгредієнти до списку покупок", - "today": "Сьогодні", - "prevWeek": "Попередній тиждень", - "nextWeek": "Наступний тиждень", - "loadError": "Не вдалося завантажити план харчування.", - "typeBreakfast": "Сніданок", - "typeLunch": "Обід", - "typeDinner": "Вечеря", - "typeSnack": "Перекус", - "dayMo": "Пн", - "dayDi": "Вт", - "dayMi": "Ср", - "dayDo": "Чт", - "dayFr": "Пт", - "daySa": "Сб", - "daySo": "Нд", - "dateLabel": "Дата", - "mealTypeLabel": "Прийом їжі", - "titleLabel": "Заголовок *", - "titlePlaceholder": "напр. Спагеті болоньєзе", - "notesLabel": "Нотатки", - "notesPlaceholder": "Необов'язково…", - "ingredientsLabel": "Інгредієнти", - "addIngredient": "Додати інгредієнт", - "ingredientNamePlaceholder": "Інгредієнт", - "ingredientQtyPlaceholder": "Кількість", - "ingredientCategoryLabel": "Категорія", - "ingredientCategoryDefault": "Різне", - "removeIngredient": "Видалити інгредієнт", - "transferLabel": "Перенести інгредієнти до списку покупок", - "transferNow": "Перенести зараз", - "noShoppingLists": "Немає доступних списків покупок", - "transferSuccess": "{{count}} інгредієнт перенесено", - "transferSuccessPlural": "{{count}} інгредієнтів перенесено", - "transferAlreadyDone": "Усі інгредієнти вже перенесено", - "ingredientCount": "{{count}} інгредієнт", - "ingredientCountPlural": "{{count}} інгредієнтів", - "titleRequired": "Заголовок є обов'язковим", - "loadingIndicator": "Завантаження…", - "recipeUrlLabel": "Посилання на рецепт (необов'язково)", - "recipeUrlPlaceholder": "https://…", - "openRecipe": "Відкрити рецепт" - }, - "calendar": { - "title": "Календар", - "newEvent": "Нова подія", - "editEvent": "Редагувати подію", - "addEvent": "Додати подію", - "deleteEvent": "Видалити подію", - "noEvents": "Немає подій у вибраному періоді.", - "today": "Сьогодні", - "back": "Назад", - "forward": "Вперед", - "viewMonth": "Місяць", - "viewWeek": "Тиждень", - "viewDay": "День", - "viewAgenda": "Порядок денний", - "allDay": "Весь день", - "allDayShort": "весь день", - "moreEvents": "+{{count}} ще", - "weekNumberLabel": "Т{{week}} · {{month}} {{year}}", - "agendaFrom": "З {{date}}", - "titleLabel": "Заголовок *", - "titlePlaceholder": "напр. Стоматолог", - "allDayToggle": "Весь день", - "startDateLabel": "Дата початку", - "startTimeLabel": "Час початку", - "endDateLabel": "Дата завершення", - "endTimeLabel": "Час завершення", - "fromLabel": "Від", - "toLabel": "До", - "locationLabel": "Місце", - "locationPlaceholder": "Необов'язково", - "assignedLabel": "Призначено", - "assignedNobody": "- Нікому -", - "colorLabel": "Колір {{color}}", - "descriptionLabel": "Опис", - "descriptionPlaceholder": "Необов'язково…", - "popupEdit": "Редагувати", - "deleteConfirm": "Справді видалити «{{title}}»?", - "createdToast": "Подію створено", - "savedToast": "Подію збережено", - "deletedToast": "Подію видалено", - "loadError": "Не вдалося завантажити події.", - "saveError": "Помилка збереження", - "deleteError": "Помилка видалення", - "titleRequired": "Заголовок є обов'язковим", - "monthJanuary": "Січень", - "monthFebruary": "Лютий", - "monthMarch": "Березень", - "monthApril": "Квітень", - "monthMay": "Травень", - "monthJune": "Червень", - "monthJuly": "Липень", - "monthAugust": "Серпень", - "monthSeptember": "Вересень", - "monthOctober": "Жовтень", - "monthNovember": "Листопад", - "monthDecember": "Грудень", - "dayShortSunday": "Нд", - "dayShortMonday": "Пн", - "dayShortTuesday": "Вт", - "dayShortWednesday": "Ср", - "dayShortThursday": "Чт", - "dayShortFriday": "Пт", - "dayShortSaturday": "Сб", - "dayLongSunday": "Неділя", - "dayLongMonday": "Понеділок", - "dayLongTuesday": "Вівторок", - "dayLongWednesday": "Середа", - "dayLongThursday": "Четвер", - "dayLongFriday": "П'ятниця", - "dayLongSaturday": "Субота", - "timeSuffix": "" - }, - "notes": { - "title": "Нотатки", - "newNote": "Нова нотатка", - "editNote": "Редагувати нотатку", - "addNoteLabel": "Нова нотатка", - "searchPlaceholder": "Пошук нотаток…", - "emptyTitle": "Нотаток поки немає", - "emptyDescription": "Створіть нову нотатку кнопкою +.", - "noResultsTitle": "Результатів немає", - "noResultsDescription": "Жодна нотатка не містить «{{query}}».", - "titleLabel": "Заголовок (необов'язково)", - "titlePlaceholder": "Без заголовку", - "contentLabel": "Вміст", - "contentMarkdownHint": "(підтримується форматування Markdown)", - "contentPlaceholder": "Введіть нотатку…", - "colorLabel": "Колір", - "pinnedLabel": "Закріпити (відображатиметься на огляді)", - "pinAction": "Закріпити", - "unpinAction": "Відкріпити", - "deleteLabel": "Видалити нотатку", - "deleteConfirm": "Справді видалити цю нотатку?", - "createdToast": "Нотатку створено", - "savedToast": "Нотатку збережено", - "deletedToast": "Нотатку видалено", - "loadError": "Не вдалося завантажити нотатки.", - "formatBold": "Жирний (Ctrl+B)", - "formatItalic": "Курсив (Ctrl+I)", - "formatUnderline": "Підкреслений (Ctrl+U)", - "formatStrikethrough": "Закреслений", - "formatHeading": "Заголовок", - "formatList": "Маркований список", - "formatOrderedList": "Нумерований список", - "formatChecklist": "Список завдань", - "formatLink": "Посилання", - "formatCode": "Код", - "formatQuote": "Цитата", - "formatDivider": "Розділювач" - }, - "contacts": { - "title": "Контакти", - "newContact": "Новий контакт", - "editContact": "Редагувати контакт", - "addButton": "Новий", - "newContactLabel": "Новий контакт", - "searchPlaceholder": "Пошук за ім'ям, телефоном або email…", - "importButton": "Імпорт", - "importLabel": "Імпортувати контакт з vCard", - "importTooltip": "Імпортувати vCard", - "emptyTitle": "Контактів поки немає", - "emptyDescription": "Додайте нові контакти кнопкою +.", - "filterAll": "Усі", - "nameLabel": "Ім'я *", - "namePlaceholder": "Повне ім'я", - "categoryLabel": "Категорія", - "phoneLabel": "Телефон", - "phonePlaceholder": "+380 …", - "emailLabel": "Email", - "emailPlaceholder": "ім'я@приклад.com", - "addressLabel": "Адреса", - "addressPlaceholder": "Вулиця, індекс місто", - "notesLabel": "Нотатки", - "notesPlaceholder": "Необов'язково…", - "callLabel": "Зателефонувати", - "emailActionLabel": "Написати email", - "mapsLabel": "Відкрити на картах", - "exportLabel": "Експортувати як vCard", - "exportTooltip": "Експортувати vCard", - "deleteLabel": "Видалити контакт", - "deleteConfirm": "Справді видалити цей контакт?", - "deletePersonConfirm": "Справді видалити «{{name}}»?", - "savedToast": "Контакт збережено", - "updatedToast": "Контакт оновлено", - "deletedToast": "Контакт видалено", - "importedToast": "{{name}} імпортовано.", - "importError": "Помилка імпорту: {{error}}", - "vcardNoName": "vCard не містить імені.", - "catDoctor": "Лікар", - "catSchool": "Школа/Дитсадок", - "catAuthority": "Державний орган", - "catInsurance": "Страхування", - "catCraftsman": "Майстер", - "catEmergency": "Екстрена служба", - "catMisc": "Різне", - "categoryDoctor": "Лікар", - "categorySchool": "Школа/Дитсадок", - "categoryAuthority": "Державний орган", - "categoryInsurance": "Страхування", - "categoryCraftsman": "Майстер", - "categoryEmergency": "Екстрена служба", - "categoryOther": "Інше" - }, - "budget": { - "title": "Бюджет", - "newEntry": "Новий запис", - "editEntry": "Редагувати запис", - "addEntryLabel": "Додати запис", - "newEntryFabLabel": "Новий запис", - "currentMonth": "Поточний", - "prevMonth": "Попередній місяць", - "nextMonth": "Наступний місяць", - "income": "Доходи", - "expenses": "Витрати", - "balance": "Баланс", - "byCategory": "За категоріями", - "transactions": "Транзакції", - "emptyTitle": "Записів у цьому місяці немає", - "emptyDescription": "Додайте записи бюджету кнопкою +.", - "csvExport": "CSV", - "typeExpense": "Витрата", - "typeIncome": "Дохід", - "titleLabel": "Заголовок *", - "titlePlaceholder": "напр. Супермаркет", - "amountLabel": "Сума *", - "amountPlaceholder": "0.00", - "categoryLabel": "Категорія", - "dateLabel": "Дата *", - "recurringLabel": "Повторюване", - "deleteLabel": "Видалити запис", - "deleteConfirm": "Справді видалити цей запис?", - "deletePersonConfirm": "Справді видалити «{{title}}»?", - "addedToast": "Запис додано", - "savedToast": "Запис збережено", - "deletedToast": "Запис видалено", - "loadError": "Не вдалося завантажити бюджет.", - "trendNeutral": "— так само, як у {{month}}", - "validAmountRequired": "Будь ласка, введіть коректну суму", - "dateRequired": "Дата є обов'язковою", - "catFood": "Продукти", - "catRent": "Оренда", - "catInsurance": "Страхування", - "catMobility": "Транспорт", - "catLeisure": "Дозвілля", - "catClothing": "Одяг", - "catHealth": "Здоров'я", - "catEducation": "Освіта", - "catMisc": "Різне", - "catEarnedIncome": "Трудовий дохід", - "catInvestmentIncome": "Інвестиційний дохід", - "catTransferGiftIncome": "Переводи та подарунки", - "catGovernmentBenefits": "Соціальні виплати", - "catOtherIncome": "Інші доходи", - "loadingIndicator": "Завантаження…" - }, - "settings": { - "title": "Налаштування", - "tabGeneral": "Загальні", - "tabMeals": "Харчування", - "tabBudget": "Бюджет", - "tabShopping": "Покупки", - "tabCalendar": "Календар", - "tabAccount": "Обліковий запис", - "tabsAriaLabel": "Розділи налаштувань", - "sectionDesign": "Зовнішній вигляд", - "sectionShopping": "Покупки", - "shoppingCategoriesLabel": "Категорії покупок", - "shoppingCategoriesHint": "Додавайте, перейменовуйте, видаляйте або змінюйте порядок категорій.", - "shoppingCategoryPlaceholder": "Нова категорія…", - "shoppingCategoryRenameHint": "Натисніть, щоб перейменувати", - "shoppingCategoryRenamePrompt": "Нова назва категорії:", - "shoppingCategoryMoveUp": "Перемістити категорію вгору", - "shoppingCategoryMoveDown": "Перемістити категорію вниз", - "shoppingCategoryDelete": "Видалити категорію", - "shoppingCategoryDeleteConfirm": "Видалити категорію «{{name}}»? Наявні товари буде переміщено до наступної категорії.", - "shoppingCategoryAdded": "Категорію додано.", - "shoppingCategoryRenamed": "Категорію перейменовано.", - "shoppingCategoryDeleted": "Категорію видалено.", - "sectionAccount": "Мій обліковий запис", - "sectionCalendarSync": "Синхронізація календаря", - "sectionFamily": "Члени родини", - "cardAppearance": "Відображення", - "themeSystem": "Системна", - "themeSysLabel": "Використовувати системні налаштування", - "themeLight": "Світла", - "themeLightLabel": "Світла тема", - "themeDark": "Темна", - "themeDarkLabel": "Темна тема", - "changePassword": "Змінити пароль", - "currentPasswordLabel": "Поточний пароль", - "newPasswordLabel": "Новий пароль", - "confirmPasswordLabel": "Підтвердити новий пароль", - "savePassword": "Зберегти пароль", - "passwordMismatch": "Паролі не збігаються.", - "passwordSavedToast": "Пароль успішно змінено.", - "googleCalendar": "Google Календар", - "appleCalendar": "Apple Календар (iCloud)", - "syncNow": "Синхронізувати зараз", - "disconnect": "Від'єднати", - "connectGoogle": "Підключити через Google", - "connected": "Підключено", - "connectedLastSync": "Підключено · Остання: {{date}}", - "notConnected": "Не підключено", - "notConfigured": "Не налаштовано (відсутні змінні .env)", - "configured": "Налаштовано (через .env)", - "configuredLastSync": "Налаштовано (через .env) · Остання: {{date}}", - "syncSuccess": "{{provider}} синхронізовано.", - "disconnectedToast": "{{provider}} від'єднано.", - "googleOnlyAdmin": "Лише адміністратор може підключити Google Календар.", - "appleOnlyAdmin": "Лише адміністратор може підключити Apple Календар.", - "caldavUrlLabel": "URL CalDAV-сервера", - "caldavUrlPlaceholder": "https://caldav.icloud.com", - "appleIdLabel": "Apple ID (email)", - "applePasswordLabel": "Пароль програми", - "applePasswordHint": "Створіть пароль на appleid.apple.com → Безпека.", - "appleConnectBtn": "Підключити та перевірити", - "appleConnecting": "Підключення…", - "appleConnectedToast": "Apple Календар підключено.", - "syncSuccessGoogle": "Синхронізацію з Google Календарем успішно підключено.", - "syncSuccessApple": "Синхронізацію з Apple Календарем успішно підключено.", - "syncErrorGoogle": "Не вдалося підключитися до Google. Спробуйте ще раз.", - "syncErrorApple": "Не вдалося підключитися до Apple. Спробуйте ще раз.", - "addMember": "+ Додати члена", - "newMemberTitle": "Новий член родини", - "usernameLabel": "Ім'я користувача", - "displayNameLabel": "Відображуване ім'я", - "memberPasswordLabel": "Пароль", - "colorLabel": "Колір", - "roleLabel": "Роль", - "roleMember": "Учасник", - "roleAdmin": "Адміністратор", - "createMember": "Створити", - "cancelAddMember": "Скасувати", - "memberAddedToast": "{{name}} додано.", - "deleteMemberConfirm": "Справді видалити {{name}}?", - "memberDeletedToast": "{{name}} видалено.", - "deleteMemberLabel": "Видалити", - "logout": "Вийти", - "synchronizing": "Синхронізація…", - "googleDisconnectConfirm": "Від'єднати Google Календар?", - "appleDisconnectConfirm": "Від'єднати Apple Календар?", - "localeSystem": "Системна", - "localeLabel": "Мова", - "languageTitle": "Мова", - "sectionMeals": "План харчування", - "mealTypesLabel": "Видимі прийоми їжі", - "mealTypesHint": "У планувальнику харчування відображатимуться лише вибрані типи прийомів їжі.", - "mealTypesSaved": "Налаштування харчування збережено.", - "mealTypesMinOne": "Має бути активним принаймні один тип прийому їжі.", - "sectionBudget": "Бюджет", - "currencyLabel": "Валюта", - "currencyHint": "Встановлює валюту, що використовується в розділі бюджету.", - "currencySaved": "Валюту збережено." - }, - "login": { - "tagline": "Планування для родини. Безпечно. Конфіденційно. Відкритий код.", - "usernameLabel": "Ім'я користувача", - "usernamePlaceholder": "ім'я користувача", - "passwordLabel": "Пароль", - "passwordPlaceholder": "••••••••", - "loginButton": "Увійти", - "loggingIn": "Вхід…", - "tooManyAttempts": "Забагато спроб. Будь ласка, зачекайте.", - "invalidCredentials": "Невірні облікові дані." - }, - "install": { - "title": "Встановити Oikos", - "subtitle": "Додати на головний екран", - "iosTip1": "Натисніть ", - "iosTip2": " → «Додати на головний екран»", - "installButton": "Встановити", - "dismissLabel": "Закрити" - }, - "modal": { - "closeLabel": "Закрити", - "overlayLabel": "Фон модального вікна" - }, - "rrule": { - "freqNone": "Без повторення", - "freqDaily": "Щодня", - "freqWeekly": "Щотижня", - "freqMonthly": "Щомісяця", - "dayMo": "Пн", - "dayTu": "Вт", - "dayWe": "Ср", - "dayTh": "Чт", - "dayFr": "Пт", - "daySa": "Сб", - "daySu": "Нд", - "labelRepeat": "Повторення", - "labelEvery": "Кожні", - "labelOnDays": "У ці дні", - "labelUntil": "Закінчується (необов'язково)", - "unitDay": "день", - "unitDays": "днів", - "unitWeek": "тиждень", - "unitWeeks": "тижнів", - "unitMonth": "місяць", - "unitMonths": "місяців" - }, - "reminders": { - "sectionTitle": "Нагадування", - "enableLabel": "Встановити нагадування", - "dateLabel": "Дата", - "timeLabel": "Час", - "offsetLabel": "Нагадати мені", - "offsetNone": "Без нагадування", - "offset15min": "За 15 хвилин", - "offset1hour": "За 1 годину", - "offset1day": "За 1 день", - "offsetAtTime": "У час події", - "toastTitle": "Нагадування", - "dismiss": "Закрити", - "notificationPermission": "Сповіщення браузера", - "notificationEnable": "Увімкнути сповіщення", - "notificationEnabled": "Сповіщення активні", - "notificationDenied": "Сповіщення заблоковано", - "notificationHint": "Отримуйте сповіщення, поки додаток відкрито.", - "pendingBadgeTitle": "{{count}} нагадування", - "pendingBadgeTitlePlural": "{{count}} нагадувань" - } + "common": { + "save": "Зберегти", + "cancel": "Скасувати", + "delete": "Видалити", + "edit": "Редагувати", + "close": "Закрити", + "create": "Створити", + "add": "Додати", + "back": "Назад", + "next": "Далі", + "loading": "Завантаження…", + "saving": "Збереження…", + "required": "Це поле є обов'язковим.", + "error": "Помилка", + "allFieldsRequired": "Будь ласка, заповніть усі поля.", + "today": "Сьогодні", + "tomorrow": "Завтра", + "skipToContent": "Перейти до вмісту", + "reload": "Оновити", + "errorOccurred": "Щось пішло не так.", + "unexpectedError": "Сталася непередбачена помилка.", + "errorGeneric": "Сталася помилка.", + "updateAvailable": "Доступне оновлення — перезавантажте сторінку, щоб отримати останню версію.", + "titleRequired": "Заголовок є обов'язковим", + "nameRequired": "Ім'я є обов'язковим", + "contentRequired": "Вміст є обов'язковим", + "all": "Усі", + "unknownError": "Невідома помилка", + "confirm": "Підтвердити", + "undo": "Скасувати" + }, + "nav": { + "dashboard": "Огляд", + "tasks": "Завдання", + "calendar": "Календар", + "meals": "Харчування", + "shopping": "Покупки", + "notes": "Нотатки", + "contacts": "Контакти", + "budget": "Бюджет", + "settings": "Налаштування", + "main": "Головна навігація", + "navigation": "Навігація", + "quickActions": "Швидкі дії" + }, + "dashboard": { + "title": "Огляд", + "greetingMorning": "Доброго ранку, {{name}}", + "greetingDay": "Доброго дня, {{name}}", + "greetingEvening": "Доброго вечора, {{name}}", + "allDone": "Усе зроблено", + "noEvents": "Немає подій", + "noPinnedNotes": "Немає закріплених нотаток", + "todayMeals": "Страви на сьогодні", + "allLink": "Усі", + "weekLink": "Тиждень", + "urgentTasksChip": "{{count}} термінове завдання", + "urgentTasksChipPlural": "{{count}} термінових завдань", + "eventsChip": "{{count}} подія сьогодні", + "eventsChipPlural": "{{count}} подій сьогодні", + "todayMealChip": "Сьогодні: {{title}}", + "loadError": "Огляд не вдалося повністю завантажити.", + "weatherRefresh": "Оновити погоду", + "weatherRefreshTitle": "Оновити", + "weatherUpdated": "Погоду оновлено", + "weatherFeelsLike": "Відчувається як {{temp}}° · {{humidity}}% · Вітер {{wind}} км/год", + "fabTaskLabel": "Додати завдання", + "fabCalendarLabel": "Додати подію", + "fabShoppingLabel": "Додати покупку", + "fabNoteLabel": "Додати нотатку", + "fabTask": "Завдання", + "fabCalendar": "Подія", + "fabShopping": "Покупка", + "fabNote": "Нотатка", + "overdue": "Прострочено", + "dueSoon": "Сьогодні", + "dueTomorrow": "Завтра", + "allDay": "Весь день", + "shoppingMore": "+{{count}} ще", + "weather": "Погода", + "customize": "Налаштувати", + "customizeTitle": "Налаштувати віджети", + "customizeReset": "Скинути", + "customizeSaved": "Огляд збережено", + "customizeMoveUp": "Перемістити вгору", + "customizeMoveDown": "Перемістити вниз" + }, + "tasks": { + "title": "Завдання", + "newTask": "Нове завдання", + "editTask": "Редагувати завдання", + "emptyTitle": "Завдань немає — все зроблено?", + "emptyDescription": "Створіть нові завдання кнопкою +.", + "titleLabel": "Заголовок *", + "titlePlaceholder": "Що потрібно зробити?", + "descriptionLabel": "Нотатка", + "descriptionPlaceholder": "Необов'язкові деталі…", + "priorityLabel": "Пріоритет", + "categoryLabel": "Категорія", + "dueDateLabel": "Термін виконання", + "dueTimeLabel": "Час", + "assignedLabel": "Призначено", + "assignedNobody": "- Нікому -", + "statusLabel": "Статус", + "priorityUrgent": "Терміново", + "priorityHigh": "Високий", + "priorityMedium": "Середній", + "priorityLow": "Низький", + "priorityNone": "Без пріоритету", + "statusOpen": "Відкрито", + "statusInProgress": "В процесі", + "statusDone": "Виконано", + "categoryHousehold": "Побут", + "categorySchool": "Навчання", + "categoryShopping": "Покупки", + "categoryRepair": "Ремонт", + "categoryHealth": "Здоров'я", + "categoryFinance": "Фінанси", + "categoryLeisure": "Дозвілля", + "categoryMisc": "Різне", + "overdue": "Прострочено", + "overdueDay": "Прострочено на {{count}} д.", + "dueToday": "Сьогодні", + "dueTomorrow": "Завтра", + "groupOverdue": "Прострочено", + "groupToday": "Сьогодні", + "groupThisWeek": "Цього тижня", + "groupNextWeek": "Наступного тижня", + "groupLater": "Пізніше", + "groupNoDate": "Без дати", + "markDone": "Позначити {{title}} як виконане", + "markOpen": "Позначити {{title}} як відкрите", + "editButton": "Редагувати завдання", + "swipeOpen": "Відкрити знову", + "swipeDone": "Виконано", + "swipeEdit": "Редагувати", + "subtaskAdd": "+ Додати підзавдання", + "subtaskToggle": "Показати підзавдання", + "subtaskMarkDone": "Позначити {{title}} як виконане", + "deleteConfirm": "Видалити завдання та всі підзавдання?", + "savedToast": "Завдання збережено.", + "createdToast": "Завдання створено.", + "deletedToast": "Завдання видалено.", + "loadError": "Не вдалося завантажити завдання.", + "subtaskPrompt": "Підзавдання:", + "kanbanOpen": "Відкрито", + "kanbanInProgress": "В процесі", + "kanbanDone": "Виконано", + "kanbanMoveToInProgress": "Позначити як «в процесі»", + "kanbanMoveToDone": "Позначити як виконане", + "kanbanMoveToOpen": "Відкрити знову", + "recurring": "Повторюване", + "listView": "Список", + "kanbanView": "Канбан" + }, + "shopping": { + "title": "Покупки", + "noLists": "Немає списків", + "noListsDescription": "Створіть список кнопкою +.", + "emptyList": "Список порожній", + "emptyListDescription": "Додайте товари через поле вводу вище.", + "newListPrompt": "Назва нового списку:", + "newListButton": "Створити новий список", + "renameListPrompt": "Нова назва списку:", + "deleteListConfirm": "Видалити список «{{name}}» та всі товари?", + "deletedListToast": "Список видалено.", + "itemDeletedToast": "«{{name}}» видалено.", + "itemsRemovedToast": "{{count}} товарів видалено.", + "clearChecked": "Видалити відмічені ({{count}})", + "itemNamePlaceholder": "Додати товар…", + "itemQtyPlaceholder": "Кількість", + "itemNameLabel": "Назва товару", + "itemQtyLabel": "Кількість", + "categoryLabel": "Категорія", + "addItemLabel": "Додати товар", + "renameListLabel": "Перейменувати список", + "deleteListLabel": "Видалити список", + "swipeBack": "Скасувати", + "swipeCheck": "Відмітити", + "swipeDelete": "Видалити", + "markDoneLabel": "Відмітити {{name}}", + "markUndoneLabel": "Зняти відмітку з {{name}}", + "deleteItemLabel": "Видалити {{name}}", + "listsLoadError": "Не вдалося завантажити списки.", + "itemsLoadError": "Не вдалося завантажити товари.", + "catFruitVeg": "Фрукти та овочі", + "catBakery": "Випічка", + "catDairy": "Молочні продукти", + "catMeatFish": "М'ясо та риба", + "catFrozen": "Заморожені продукти", + "catDrinks": "Напої", + "catHousehold": "Господарські товари", + "catDrugstore": "Аптека", + "catMisc": "Різне" + }, + "meals": { + "title": "План харчування", + "noMealPlanned": "Страву не заплановано", + "addMeal": "Додати {{type}}", + "editMeal": "Редагувати страву", + "addMealTitle": "Додати страву", + "deleteMeal": "Видалити страву", + "transferToShoppingList": "Додати інгредієнти до списку покупок", + "today": "Сьогодні", + "prevWeek": "Попередній тиждень", + "nextWeek": "Наступний тиждень", + "loadError": "Не вдалося завантажити план харчування.", + "typeBreakfast": "Сніданок", + "typeLunch": "Обід", + "typeDinner": "Вечеря", + "typeSnack": "Перекус", + "dayMo": "Пн", + "dayDi": "Вт", + "dayMi": "Ср", + "dayDo": "Чт", + "dayFr": "Пт", + "daySa": "Сб", + "daySo": "Нд", + "dateLabel": "Дата", + "mealTypeLabel": "Прийом їжі", + "titleLabel": "Заголовок *", + "titlePlaceholder": "напр. Спагеті болоньєзе", + "notesLabel": "Нотатки", + "notesPlaceholder": "Необов'язково…", + "ingredientsLabel": "Інгредієнти", + "addIngredient": "Додати інгредієнт", + "ingredientNamePlaceholder": "Інгредієнт", + "ingredientQtyPlaceholder": "Кількість", + "ingredientCategoryLabel": "Категорія", + "ingredientCategoryDefault": "Різне", + "removeIngredient": "Видалити інгредієнт", + "transferLabel": "Перенести інгредієнти до списку покупок", + "transferNow": "Перенести зараз", + "noShoppingLists": "Немає доступних списків покупок", + "transferSuccess": "{{count}} інгредієнт перенесено", + "transferSuccessPlural": "{{count}} інгредієнтів перенесено", + "transferAlreadyDone": "Усі інгредієнти вже перенесено", + "ingredientCount": "{{count}} інгредієнт", + "ingredientCountPlural": "{{count}} інгредієнтів", + "titleRequired": "Заголовок є обов'язковим", + "loadingIndicator": "Завантаження…", + "recipeUrlLabel": "Посилання на рецепт (необов'язково)", + "recipeUrlPlaceholder": "https://…", + "openRecipe": "Відкрити рецепт" + }, + "calendar": { + "title": "Календар", + "newEvent": "Нова подія", + "editEvent": "Редагувати подію", + "addEvent": "Додати подію", + "deleteEvent": "Видалити подію", + "noEvents": "Немає подій у вибраному періоді.", + "today": "Сьогодні", + "back": "Назад", + "forward": "Вперед", + "viewMonth": "Місяць", + "viewWeek": "Тиждень", + "viewDay": "День", + "viewAgenda": "Порядок денний", + "allDay": "Весь день", + "allDayShort": "весь день", + "moreEvents": "+{{count}} ще", + "weekNumberLabel": "Т{{week}} · {{month}} {{year}}", + "agendaFrom": "З {{date}}", + "titleLabel": "Заголовок *", + "titlePlaceholder": "напр. Стоматолог", + "allDayToggle": "Весь день", + "startDateLabel": "Дата початку", + "startTimeLabel": "Час початку", + "endDateLabel": "Дата завершення", + "endTimeLabel": "Час завершення", + "fromLabel": "Від", + "toLabel": "До", + "locationLabel": "Місце", + "locationPlaceholder": "Необов'язково", + "assignedLabel": "Призначено", + "assignedNobody": "- Нікому -", + "colorLabel": "Колір {{color}}", + "descriptionLabel": "Опис", + "descriptionPlaceholder": "Необов'язково…", + "popupEdit": "Редагувати", + "deleteConfirm": "Справді видалити «{{title}}»?", + "createdToast": "Подію створено", + "savedToast": "Подію збережено", + "deletedToast": "Подію видалено", + "loadError": "Не вдалося завантажити події.", + "saveError": "Помилка збереження", + "deleteError": "Помилка видалення", + "titleRequired": "Заголовок є обов'язковим", + "monthJanuary": "Січень", + "monthFebruary": "Лютий", + "monthMarch": "Березень", + "monthApril": "Квітень", + "monthMay": "Травень", + "monthJune": "Червень", + "monthJuly": "Липень", + "monthAugust": "Серпень", + "monthSeptember": "Вересень", + "monthOctober": "Жовтень", + "monthNovember": "Листопад", + "monthDecember": "Грудень", + "dayShortSunday": "Нд", + "dayShortMonday": "Пн", + "dayShortTuesday": "Вт", + "dayShortWednesday": "Ср", + "dayShortThursday": "Чт", + "dayShortFriday": "Пт", + "dayShortSaturday": "Сб", + "dayLongSunday": "Неділя", + "dayLongMonday": "Понеділок", + "dayLongTuesday": "Вівторок", + "dayLongWednesday": "Середа", + "dayLongThursday": "Четвер", + "dayLongFriday": "П'ятниця", + "dayLongSaturday": "Субота", + "timeSuffix": "" + }, + "notes": { + "title": "Нотатки", + "newNote": "Нова нотатка", + "editNote": "Редагувати нотатку", + "addNoteLabel": "Нова нотатка", + "searchPlaceholder": "Пошук нотаток…", + "emptyTitle": "Нотаток поки немає", + "emptyDescription": "Створіть нову нотатку кнопкою +.", + "noResultsTitle": "Результатів немає", + "noResultsDescription": "Жодна нотатка не містить «{{query}}».", + "titleLabel": "Заголовок (необов'язково)", + "titlePlaceholder": "Без заголовку", + "contentLabel": "Вміст", + "contentMarkdownHint": "(підтримується форматування Markdown)", + "contentPlaceholder": "Введіть нотатку…", + "colorLabel": "Колір", + "pinnedLabel": "Закріпити (відображатиметься на огляді)", + "pinAction": "Закріпити", + "unpinAction": "Відкріпити", + "deleteLabel": "Видалити нотатку", + "deleteConfirm": "Справді видалити цю нотатку?", + "createdToast": "Нотатку створено", + "savedToast": "Нотатку збережено", + "deletedToast": "Нотатку видалено", + "loadError": "Не вдалося завантажити нотатки.", + "formatBold": "Жирний (Ctrl+B)", + "formatItalic": "Курсив (Ctrl+I)", + "formatUnderline": "Підкреслений (Ctrl+U)", + "formatStrikethrough": "Закреслений", + "formatHeading": "Заголовок", + "formatList": "Маркований список", + "formatOrderedList": "Нумерований список", + "formatChecklist": "Список завдань", + "formatLink": "Посилання", + "formatCode": "Код", + "formatQuote": "Цитата", + "formatDivider": "Розділювач" + }, + "contacts": { + "title": "Контакти", + "newContact": "Новий контакт", + "editContact": "Редагувати контакт", + "addButton": "Новий", + "newContactLabel": "Новий контакт", + "searchPlaceholder": "Пошук за ім'ям, телефоном або email…", + "importButton": "Імпорт", + "importLabel": "Імпортувати контакт з vCard", + "importTooltip": "Імпортувати vCard", + "emptyTitle": "Контактів поки немає", + "emptyDescription": "Додайте нові контакти кнопкою +.", + "filterAll": "Усі", + "nameLabel": "Ім'я *", + "namePlaceholder": "Повне ім'я", + "categoryLabel": "Категорія", + "phoneLabel": "Телефон", + "phonePlaceholder": "+380 …", + "emailLabel": "Email", + "emailPlaceholder": "ім'я@приклад.com", + "addressLabel": "Адреса", + "addressPlaceholder": "Вулиця, індекс місто", + "notesLabel": "Нотатки", + "notesPlaceholder": "Необов'язково…", + "callLabel": "Зателефонувати", + "emailActionLabel": "Написати email", + "mapsLabel": "Відкрити на картах", + "exportLabel": "Експортувати як vCard", + "exportTooltip": "Експортувати vCard", + "deleteLabel": "Видалити контакт", + "deleteConfirm": "Справді видалити цей контакт?", + "deletePersonConfirm": "Справді видалити «{{name}}»?", + "savedToast": "Контакт збережено", + "updatedToast": "Контакт оновлено", + "deletedToast": "Контакт видалено", + "importedToast": "{{name}} імпортовано.", + "importError": "Помилка імпорту: {{error}}", + "vcardNoName": "vCard не містить імені.", + "catDoctor": "Лікар", + "catSchool": "Школа/Дитсадок", + "catAuthority": "Державний орган", + "catInsurance": "Страхування", + "catCraftsman": "Майстер", + "catEmergency": "Екстрена служба", + "catMisc": "Різне", + "categoryDoctor": "Лікар", + "categorySchool": "Школа/Дитсадок", + "categoryAuthority": "Державний орган", + "categoryInsurance": "Страхування", + "categoryCraftsman": "Майстер", + "categoryEmergency": "Екстрена служба", + "categoryOther": "Інше" + }, + "budget": { + "title": "Бюджет", + "newEntry": "Новий запис", + "editEntry": "Редагувати запис", + "addEntryLabel": "Додати запис", + "newEntryFabLabel": "Новий запис", + "currentMonth": "Поточний", + "prevMonth": "Попередній місяць", + "nextMonth": "Наступний місяць", + "income": "Доходи", + "expenses": "Витрати", + "balance": "Баланс", + "byCategory": "За категоріями", + "transactions": "Транзакції", + "emptyTitle": "Записів у цьому місяці немає", + "emptyDescription": "Додайте записи бюджету кнопкою +.", + "csvExport": "CSV", + "typeExpense": "Витрата", + "typeIncome": "Дохід", + "titleLabel": "Заголовок *", + "titlePlaceholder": "напр. Супермаркет", + "amountLabel": "Сума *", + "amountPlaceholder": "0.00", + "categoryLabel": "Категорія", + "dateLabel": "Дата *", + "recurringLabel": "Повторюване", + "deleteLabel": "Видалити запис", + "deleteConfirm": "Справді видалити цей запис?", + "deletePersonConfirm": "Справді видалити «{{title}}»?", + "addedToast": "Запис додано", + "savedToast": "Запис збережено", + "deletedToast": "Запис видалено", + "loadError": "Не вдалося завантажити бюджет.", + "trendNeutral": "— так само, як у {{month}}", + "validAmountRequired": "Будь ласка, введіть коректну суму", + "dateRequired": "Дата є обов'язковою", + "catFood": "Продукти", + "catRent": "Оренда", + "catInsurance": "Страхування", + "catMobility": "Транспорт", + "catLeisure": "Дозвілля", + "catClothing": "Одяг", + "catHealth": "Здоров'я", + "catEducation": "Освіта", + "catMisc": "Різне", + "catEarnedIncome": "Трудовий дохід", + "catInvestmentIncome": "Інвестиційний дохід", + "catTransferGiftIncome": "Переводи та подарунки", + "catGovernmentBenefits": "Соціальні виплати", + "catOtherIncome": "Інші доходи", + "loadingIndicator": "Завантаження…" + }, + "settings": { + "title": "Налаштування", + "tabGeneral": "Загальні", + "tabMeals": "Харчування", + "tabBudget": "Бюджет", + "tabShopping": "Покупки", + "tabCalendar": "Календар", + "tabAccount": "Обліковий запис", + "tabsAriaLabel": "Розділи налаштувань", + "sectionDesign": "Зовнішній вигляд", + "sectionShopping": "Покупки", + "shoppingCategoriesLabel": "Категорії покупок", + "shoppingCategoriesHint": "Додавайте, перейменовуйте, видаляйте або змінюйте порядок категорій.", + "shoppingCategoryPlaceholder": "Нова категорія…", + "shoppingCategoryRenameHint": "Натисніть, щоб перейменувати", + "shoppingCategoryRenamePrompt": "Нова назва категорії:", + "shoppingCategoryMoveUp": "Перемістити категорію вгору", + "shoppingCategoryMoveDown": "Перемістити категорію вниз", + "shoppingCategoryDelete": "Видалити категорію", + "shoppingCategoryDeleteConfirm": "Видалити категорію «{{name}}»? Наявні товари буде переміщено до наступної категорії.", + "shoppingCategoryAdded": "Категорію додано.", + "shoppingCategoryRenamed": "Категорію перейменовано.", + "shoppingCategoryDeleted": "Категорію видалено.", + "sectionAccount": "Мій обліковий запис", + "sectionCalendarSync": "Синхронізація календаря", + "sectionFamily": "Члени родини", + "cardAppearance": "Відображення", + "themeSystem": "Системна", + "themeSysLabel": "Використовувати системні налаштування", + "themeLight": "Світла", + "themeLightLabel": "Світла тема", + "themeDark": "Темна", + "themeDarkLabel": "Темна тема", + "changePassword": "Змінити пароль", + "currentPasswordLabel": "Поточний пароль", + "newPasswordLabel": "Новий пароль", + "confirmPasswordLabel": "Підтвердити новий пароль", + "savePassword": "Зберегти пароль", + "passwordMismatch": "Паролі не збігаються.", + "passwordSavedToast": "Пароль успішно змінено.", + "googleCalendar": "Google Календар", + "appleCalendar": "Apple Календар (iCloud)", + "syncNow": "Синхронізувати зараз", + "disconnect": "Від'єднати", + "connectGoogle": "Підключити через Google", + "connected": "Підключено", + "connectedLastSync": "Підключено · Остання: {{date}}", + "notConnected": "Не підключено", + "notConfigured": "Не налаштовано (відсутні змінні .env)", + "configured": "Налаштовано (через .env)", + "configuredLastSync": "Налаштовано (через .env) · Остання: {{date}}", + "syncSuccess": "{{provider}} синхронізовано.", + "disconnectedToast": "{{provider}} від'єднано.", + "googleOnlyAdmin": "Лише адміністратор може підключити Google Календар.", + "appleOnlyAdmin": "Лише адміністратор може підключити Apple Календар.", + "caldavUrlLabel": "URL CalDAV-сервера", + "caldavUrlPlaceholder": "https://caldav.icloud.com", + "appleIdLabel": "Apple ID (email)", + "applePasswordLabel": "Пароль програми", + "applePasswordHint": "Створіть пароль на appleid.apple.com → Безпека.", + "appleConnectBtn": "Підключити та перевірити", + "appleConnecting": "Підключення…", + "appleConnectedToast": "Apple Календар підключено.", + "syncSuccessGoogle": "Синхронізацію з Google Календарем успішно підключено.", + "syncSuccessApple": "Синхронізацію з Apple Календарем успішно підключено.", + "syncErrorGoogle": "Не вдалося підключитися до Google. Спробуйте ще раз.", + "syncErrorApple": "Не вдалося підключитися до Apple. Спробуйте ще раз.", + "addMember": "+ Додати члена", + "newMemberTitle": "Новий член родини", + "usernameLabel": "Ім'я користувача", + "displayNameLabel": "Відображуване ім'я", + "memberPasswordLabel": "Пароль", + "colorLabel": "Колір", + "roleLabel": "Роль", + "roleMember": "Учасник", + "roleAdmin": "Адміністратор", + "createMember": "Створити", + "cancelAddMember": "Скасувати", + "memberAddedToast": "{{name}} додано.", + "deleteMemberConfirm": "Справді видалити {{name}}?", + "memberDeletedToast": "{{name}} видалено.", + "deleteMemberLabel": "Видалити", + "logout": "Вийти", + "synchronizing": "Синхронізація…", + "googleDisconnectConfirm": "Від'єднати Google Календар?", + "appleDisconnectConfirm": "Від'єднати Apple Календар?", + "localeSystem": "Системна", + "localeLabel": "Мова", + "languageTitle": "Мова", + "sectionMeals": "План харчування", + "mealTypesLabel": "Видимі прийоми їжі", + "mealTypesHint": "У планувальнику харчування відображатимуться лише вибрані типи прийомів їжі.", + "mealTypesSaved": "Налаштування харчування збережено.", + "mealTypesMinOne": "Має бути активним принаймні один тип прийому їжі.", + "sectionBudget": "Бюджет", + "currencyLabel": "Валюта", + "currencyHint": "Встановлює валюту, що використовується в розділі бюджету.", + "currencySaved": "Валюту збережено." + }, + "login": { + "tagline": "Планування для родини. Безпечно. Конфіденційно. Відкритий код.", + "usernameLabel": "Ім'я користувача", + "usernamePlaceholder": "ім'я користувача", + "passwordLabel": "Пароль", + "passwordPlaceholder": "••••••••", + "loginButton": "Увійти", + "loggingIn": "Вхід…", + "tooManyAttempts": "Забагато спроб. Будь ласка, зачекайте.", + "invalidCredentials": "Невірні облікові дані.", + "version": "v{{version}}" + }, + "install": { + "title": "Встановити Oikos", + "subtitle": "Додати на головний екран", + "iosTip1": "Натисніть ", + "iosTip2": " → «Додати на головний екран»", + "installButton": "Встановити", + "dismissLabel": "Закрити" + }, + "modal": { + "closeLabel": "Закрити", + "overlayLabel": "Фон модального вікна" + }, + "rrule": { + "freqNone": "Без повторення", + "freqDaily": "Щодня", + "freqWeekly": "Щотижня", + "freqMonthly": "Щомісяця", + "dayMo": "Пн", + "dayTu": "Вт", + "dayWe": "Ср", + "dayTh": "Чт", + "dayFr": "Пт", + "daySa": "Сб", + "daySu": "Нд", + "labelRepeat": "Повторення", + "labelEvery": "Кожні", + "labelOnDays": "У ці дні", + "labelUntil": "Закінчується (необов'язково)", + "unitDay": "день", + "unitDays": "днів", + "unitWeek": "тиждень", + "unitWeeks": "тижнів", + "unitMonth": "місяць", + "unitMonths": "місяців" + }, + "reminders": { + "sectionTitle": "Нагадування", + "enableLabel": "Встановити нагадування", + "dateLabel": "Дата", + "timeLabel": "Час", + "offsetLabel": "Нагадати мені", + "offsetNone": "Без нагадування", + "offset15min": "За 15 хвилин", + "offset1hour": "За 1 годину", + "offset1day": "За 1 день", + "offsetAtTime": "У час події", + "toastTitle": "Нагадування", + "dismiss": "Закрити", + "notificationPermission": "Сповіщення браузера", + "notificationEnable": "Увімкнути сповіщення", + "notificationEnabled": "Сповіщення активні", + "notificationDenied": "Сповіщення заблоковано", + "notificationHint": "Отримуйте сповіщення, поки додаток відкрито.", + "pendingBadgeTitle": "{{count}} нагадування", + "pendingBadgeTitlePlural": "{{count}} нагадувань" + } } diff --git a/public/locales/zh.json b/public/locales/zh.json index d6eab31..4062696 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -567,7 +567,8 @@ "loginButton": "登录", "loggingIn": "登录中…", "tooManyAttempts": "尝试次数过多,请稍后再试。", - "invalidCredentials": "用户名或密码错误。" + "invalidCredentials": "用户名或密码错误。", + "version": "v{{version}}" }, "install": { "title": "安装 Oikos", @@ -604,4 +605,4 @@ "unitMonth": "个月", "unitMonths": "个月" } -} \ No newline at end of file +} diff --git a/public/pages/login.js b/public/pages/login.js index f2bd2f0..464317e 100644 --- a/public/pages/login.js +++ b/public/pages/login.js @@ -7,6 +7,8 @@ import { auth } from '/api.js'; import { t } from '/i18n.js'; +const VERSION_URL = '/api/v1/version'; + /** * Rendert die Login-Seite in den gegebenen Container. * @param {HTMLElement} container @@ -56,12 +58,19 @@ export async function render(container) { +
`; const form = container.querySelector('#login-form'); const errorEl = container.querySelector('#login-error'); const submitBtn = container.querySelector('#login-btn'); + const versionEl = container.querySelector('#login-version'); + + fetch(VERSION_URL) + .then((r) => r.json()) + .then((d) => { versionEl.textContent = t('login.version', { version: d.version }); }) + .catch(() => {}); form.addEventListener('submit', async (e) => { e.preventDefault(); diff --git a/public/router.js b/public/router.js index c1a578f..8145077 100644 --- a/public/router.js +++ b/public/router.js @@ -171,6 +171,10 @@ async function navigate(path, userOrPushState = true, pushState = true) { } catch { currentPath = null; // Reset damit navigate('/login') nicht geblockt wird isNavigating = false; + // _pendingLoginRedirect leeren: der catch ruft navigate('/login') direkt auf, + // der finally soll keinen zweiten Aufruf starten (würde isNavigating=true setzen, + // während die Login-Seite rendert, und so post-login navigate blockieren). + _pendingLoginRedirect = false; navigate('/login'); return; } diff --git a/public/styles/login.css b/public/styles/login.css index 005f1f8..7a064a7 100644 --- a/public/styles/login.css +++ b/public/styles/login.css @@ -55,3 +55,11 @@ font-size: var(--text-sm); margin-bottom: var(--space-4); } + +.login-version { + margin-top: var(--space-4); + font-size: var(--text-xs); + color: var(--color-text-tertiary, var(--color-text-secondary)); + text-align: center; + opacity: 0.6; +} diff --git a/server/index.js b/server/index.js index ebb3f48..321735f 100644 --- a/server/index.js +++ b/server/index.js @@ -8,6 +8,7 @@ import express from 'express'; import helmet from 'helmet'; import rateLimit from 'express-rate-limit'; import path from 'path'; +import { readFileSync } from 'node:fs'; import { createLogger } from './logger.js'; import * as db from './db.js'; import { router as authRouter, sessionMiddleware, requireAuth } from './auth.js'; @@ -32,6 +33,10 @@ const log = createLogger('Server'); const logSync = createLogger('Sync'); const logOikos = createLogger('Oikos'); +const { version: APP_VERSION } = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf-8') +); + const app = express(); const PORT = process.env.PORT || 3000; @@ -155,6 +160,11 @@ app.use('/api/', apiLimiter); // -------------------------------------------------------- app.use('/api/v1/auth', authRouter); +// Versionsinformation - keine Authentifizierung erforderlich (Login-Seite benötigt diese) +app.get('/api/v1/version', (req, res) => { + res.json({ version: APP_VERSION }); +}); + // Alle weiteren API-Routen erfordern Authentifizierung + CSRF-Schutz app.use('/api/v1', requireAuth); app.use('/api/v1', csrfMiddleware);