From 202cfb6a630383b1dd54718d20eb61787dd64c49 Mon Sep 17 00:00:00 2001 From: quentinClaudel Date: Sun, 29 Mar 2026 23:36:56 +0200 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20improve=20French=20translations=20?= =?UTF-8?q?=E2=80=94=20thanks=20@quentinClaudel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/Vacay/VacayMonthCard.tsx | 10 +- client/src/i18n/translations/fr.ts | 140 +++++++++--------- 2 files changed, 77 insertions(+), 73 deletions(-) diff --git a/client/src/components/Vacay/VacayMonthCard.tsx b/client/src/components/Vacay/VacayMonthCard.tsx index 9c1e8ad..708acb9 100644 --- a/client/src/components/Vacay/VacayMonthCard.tsx +++ b/client/src/components/Vacay/VacayMonthCard.tsx @@ -6,10 +6,13 @@ import type { HolidaysMap, VacayEntry } from '../../types' const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'] const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] const WEEKDAYS_ES = ['Lu', 'Ma', 'Mi', 'Ju', 'Vi', 'Sa', 'Do'] +const WEEKDAYS_FR = ['Lu', 'Ma', 'Me', 'Je', 'Ve', 'Sa', 'Di'] const WEEKDAYS_AR = ['اث', 'ثل', 'أر', 'خم', 'جم', 'سب', 'أح'] + const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'] const MONTHS_ES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'] +const MONTHS_FR = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'] const MONTHS_AR = ['يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر'] function hexToRgba(hex: string, alpha: number): string { @@ -37,9 +40,10 @@ export default function VacayMonthCard({ onCellClick, companyMode, blockWeekends, weekendDays = [0, 6] }: VacayMonthCardProps) { const { language } = useTranslation() - const weekdays = language === 'de' ? WEEKDAYS_DE : language === 'es' ? WEEKDAYS_ES : language === 'ar' ? WEEKDAYS_AR : WEEKDAYS_EN - const monthNames = language === 'de' ? MONTHS_DE : language === 'es' ? MONTHS_ES : language === 'ar' ? MONTHS_AR : MONTHS_EN - + + const weekdays = language === 'de' ? WEEKDAYS_DE : language === 'es' ? WEEKDAYS_ES : language === 'fr' ? WEEKDAYS_FR : language === 'ar' ? WEEKDAYS_AR : WEEKDAYS_EN + const monthNames = language === 'de' ? MONTHS_DE : language === 'es' ? MONTHS_ES : language === 'fr' ? MONTHS_FR : language === 'ar' ? MONTHS_AR : MONTHS_EN + const weeks = useMemo(() => { const firstDay = new Date(year, month, 1) const daysInMonth = new Date(year, month + 1, 0).getDate() diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 722e8d8..f610cc0 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -5,13 +5,13 @@ const fr: Record = { 'common.delete': 'Supprimer', 'common.edit': 'Modifier', 'common.add': 'Ajouter', - 'common.loading': 'Chargement...', + 'common.loading': 'Chargement…', 'common.error': 'Erreur', 'common.back': 'Retour', 'common.all': 'Tout', 'common.close': 'Fermer', 'common.open': 'Ouvrir', - 'common.upload': 'Téléverser', + 'common.upload': 'Importer', 'common.search': 'Rechercher', 'common.confirm': 'Confirmer', 'common.ok': 'OK', @@ -24,10 +24,10 @@ const fr: Record = { 'common.name': 'Nom', 'common.email': 'E-mail', 'common.password': 'Mot de passe', - 'common.saving': 'Enregistrement...', + 'common.saving': 'Enregistrement…', 'common.update': 'Mettre à jour', 'common.change': 'Modifier', - 'common.uploading': 'Téléversement…', + 'common.uploading': 'Import en cours…', 'common.backToPlanning': 'Retour à la planification', 'common.reset': 'Réinitialiser', @@ -44,7 +44,7 @@ const fr: Record = { // Dashboard 'dashboard.title': 'Mes voyages', - 'dashboard.subtitle.loading': 'Chargement des voyages...', + 'dashboard.subtitle.loading': 'Chargement des voyages…', 'dashboard.subtitle.trips': '{count} voyages ({archived} archivés)', 'dashboard.subtitle.empty': 'Commencez votre premier voyage', 'dashboard.subtitle.activeOne': '{count} voyage actif', @@ -54,8 +54,8 @@ const fr: Record = { 'dashboard.gridView': 'Vue en grille', 'dashboard.listView': 'Vue en liste', 'dashboard.currency': 'Devise', - 'dashboard.timezone': 'Fuseaux horaires', - 'dashboard.localTime': 'Local', + 'dashboard.timezone': 'Fuseau horaire', + 'dashboard.localTime': 'Heure locale', 'dashboard.timezoneCustomTitle': 'Fuseau horaire personnalisé', 'dashboard.timezoneCustomLabelPlaceholder': 'Libellé (facultatif)', 'dashboard.timezoneCustomTzPlaceholder': 'ex. America/New_York', @@ -105,7 +105,7 @@ const fr: Record = { 'dashboard.addMembers': 'Compagnons de voyage', 'dashboard.addMember': 'Ajouter un membre', 'dashboard.coverSaved': 'Image de couverture enregistrée', - 'dashboard.coverUploadError': 'Échec du téléversement', + 'dashboard.coverUploadError': 'Échec de l\'import', 'dashboard.coverRemoveError': 'Échec de la suppression', 'dashboard.titleRequired': 'Le titre est obligatoire', 'dashboard.endDateError': 'La date de fin doit être postérieure à la date de début', @@ -115,7 +115,7 @@ const fr: Record = { 'settings.subtitle': 'Configurez vos paramètres personnels', 'settings.map': 'Carte', 'settings.mapTemplate': 'Modèle de carte', - 'settings.mapTemplatePlaceholder.select': 'Sélectionner un modèle...', + 'settings.mapTemplatePlaceholder.select': 'Sélectionner un modèle…', 'settings.mapDefaultHint': 'Laissez vide pour OpenStreetMap (par défaut)', 'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', 'settings.mapHint': 'Modèle d\'URL pour les tuiles de carte', @@ -127,7 +127,7 @@ const fr: Record = { 'settings.mapsKeyHint': 'Pour la recherche de lieux. Nécessite l\'API Places (New). Obtenez-la sur console.cloud.google.com', 'settings.weatherKey': 'Clé API OpenWeatherMap', 'settings.weatherKeyHint': 'Pour les données météo. Gratuit sur openweathermap.org/api', - 'settings.keyPlaceholder': 'Saisir la clé...', + 'settings.keyPlaceholder': 'Saisir la clé…', 'settings.configured': 'Configuré', 'settings.saveKeys': 'Enregistrer les clés', 'settings.display': 'Affichage', @@ -186,15 +186,15 @@ const fr: Record = { 'settings.toast.keysSaved': 'Clés API enregistrées', 'settings.toast.displaySaved': 'Paramètres d\'affichage enregistrés', 'settings.toast.profileSaved': 'Profil enregistré', - 'settings.uploadAvatar': 'Téléverser une photo de profil', + 'settings.uploadAvatar': 'Importer une photo de profil', 'settings.removeAvatar': 'Supprimer la photo de profil', 'settings.avatarUploaded': 'Photo de profil mise à jour', 'settings.avatarRemoved': 'Photo de profil supprimée', - 'settings.avatarError': 'Échec du téléversement', + 'settings.avatarError': 'Échec de l\'import', // Login 'login.error': 'Échec de la connexion. Veuillez vérifier vos identifiants.', - 'login.tagline': 'Vos voyages.\nVotre plan.', + 'login.tagline': 'Vos voyages.\nVotre organisation.', 'login.description': 'Planifiez vos voyages en collaboration avec des cartes interactives, des budgets et la synchronisation en temps réel.', 'login.features.maps': 'Cartes interactives', 'login.features.mapsDesc': 'Google Places, itinéraires et regroupement', @@ -209,7 +209,7 @@ const fr: Record = { 'login.features.bookings': 'Réservations', 'login.features.bookingsDesc': 'Vols, hôtels, restaurants et plus', 'login.features.files': 'Documents', - 'login.features.filesDesc': 'Téléversez et gérez vos documents', + 'login.features.filesDesc': 'Importez et gérez vos documents', 'login.features.routes': 'Itinéraires intelligents', 'login.features.routesDesc': 'Optimisation automatique et export Google Maps', 'login.selfHosted': 'Auto-hébergé · Open Source · Vos données restent les vôtres', @@ -260,7 +260,7 @@ const fr: Record = { 'register.minChars': 'Min. 6 caractères', 'register.confirmPassword': 'Confirmer le mot de passe', 'register.repeatPassword': 'Répéter le mot de passe', - 'register.registering': 'Inscription en cours...', + 'register.registering': 'Inscription en cours…', 'register.register': 'S\'inscrire', 'register.hasAccount': 'Vous avez déjà un compte ?', 'register.signIn': 'Se connecter', @@ -343,7 +343,7 @@ const fr: Record = { // File Types 'admin.fileTypes': 'Types de fichiers autorisés', - 'admin.fileTypesHint': 'Configurez les types de fichiers que les utilisateurs peuvent téléverser.', + 'admin.fileTypesHint': 'Configurez les types de fichiers que les utilisateurs peuvent importer.', 'admin.fileTypesFormat': 'Extensions séparées par des virgules (ex. jpg,png,pdf,doc). Utilisez * pour autoriser tous les types.', 'admin.fileTypesSaved': 'Paramètres des types de fichiers enregistrés', @@ -381,11 +381,11 @@ const fr: Record = { 'admin.addons.catalog.budget.description': 'Suivez les dépenses et planifiez votre budget de voyage', 'admin.addons.catalog.documents.name': 'Documents', 'admin.addons.catalog.documents.description': 'Stockez et gérez vos documents de voyage', - 'admin.addons.catalog.vacay.name': 'Vacay', + 'admin.addons.catalog.vacay.name': 'Vacances', 'admin.addons.catalog.vacay.description': 'Planificateur de vacances personnel avec vue calendrier', 'admin.addons.catalog.atlas.name': 'Atlas', 'admin.addons.catalog.atlas.description': 'Carte du monde avec pays visités et statistiques de voyage', - 'admin.addons.catalog.collab.name': 'Collab', + 'admin.addons.catalog.collab.name': 'Collaboration', 'admin.addons.catalog.collab.description': 'Notes en temps réel, sondages et chat pour la planification de voyage', 'admin.addons.subtitleBefore': 'Activez ou désactivez des fonctionnalités pour personnaliser votre expérience ', 'admin.addons.subtitleAfter': '.', @@ -408,7 +408,7 @@ const fr: Record = { 'admin.weather.climateDesc': 'Moyennes des 85 dernières années pour les jours au-delà des prévisions de 16 jours', 'admin.weather.requests': '10 000 requêtes / jour', 'admin.weather.requestsDesc': 'Gratuit, aucune clé API requise', - 'admin.weather.locationHint': 'La météo est basée sur le premier lieu avec des coordonnées de chaque jour. Si aucun lieu n\'est assigné à un jour, un lieu de la liste est utilisé comme référence.', + 'admin.weather.locationHint': 'La météo est basée sur le premier lieu avec des coordonnées de chaque jour. Si aucun lieu n\'est attribué à un jour, un lieu de la liste est utilisé comme référence.', // GitHub 'admin.tabs.github': 'GitHub', @@ -419,8 +419,8 @@ const fr: Record = { 'admin.github.showDetails': 'Afficher les détails', 'admin.github.hideDetails': 'Masquer les détails', 'admin.github.loadMore': 'Charger plus', - 'admin.github.loading': 'Chargement...', - 'admin.github.support': 'Aide à continuer le développement de TREK', + 'admin.github.loading': 'Chargement…', + 'admin.github.support': 'Aidez à poursuivre le développement de TREK', 'admin.github.error': 'Impossible de charger les versions', 'admin.github.by': 'par', @@ -430,7 +430,7 @@ const fr: Record = { 'admin.update.install': 'Installer la mise à jour', 'admin.update.confirmTitle': 'Installer la mise à jour ?', 'admin.update.confirmText': 'TREK sera mis à jour de {current} vers {version}. Le serveur redémarrera automatiquement ensuite.', - 'admin.update.dataInfo': 'Toutes vos données (voyages, utilisateurs, clés API, téléversements, Vacay, Atlas, budgets) seront préservées.', + 'admin.update.dataInfo': 'Toutes vos données (voyages, utilisateurs, clés API, importations, Vacances, Atlas, budgets) seront préservées.', 'admin.update.warning': 'L\'application sera brièvement indisponible pendant le redémarrage.', 'admin.update.confirm': 'Mettre à jour maintenant', 'admin.update.installing': 'Mise à jour…', @@ -443,7 +443,7 @@ const fr: Record = { 'admin.update.reloadHint': 'Veuillez recharger la page dans quelques secondes.', // Vacay addon - 'vacay.subtitle': 'Planifiez et gérez vos jours de congé', + 'vacay.subtitle': 'Planifiez et gérez vos jours de congés', 'vacay.settings': 'Paramètres', 'vacay.year': 'Année', 'vacay.addYear': 'Ajouter une année', @@ -490,11 +490,11 @@ const fr: Record = { 'vacay.shareEmailPlaceholder': 'E-mail de l\'utilisateur TREK', 'vacay.shareSuccess': 'Plan partagé avec succès', 'vacay.shareError': 'Impossible de partager le plan', - 'vacay.dissolve': 'Dissoudre la fusion', + 'vacay.dissolve': 'Séparer les calendriers', 'vacay.dissolveHint': 'Séparer à nouveau les calendriers. Vos entrées seront conservées.', 'vacay.dissolveAction': 'Dissoudre', 'vacay.dissolved': 'Calendrier séparé', - 'vacay.fusedWith': 'Fusionné avec', + 'vacay.fusedWith': 'Partagé avec', 'vacay.you': 'vous', 'vacay.noData': 'Aucune donnée', 'vacay.changeColor': 'Changer la couleur', @@ -580,14 +580,14 @@ const fr: Record = { 'trip.tabs.packingShort': 'Bagages', 'trip.tabs.budget': 'Budget', 'trip.tabs.files': 'Fichiers', - 'trip.loading': 'Chargement du voyage...', + 'trip.loading': 'Chargement du voyage…', 'trip.mobilePlan': 'Plan', 'trip.mobilePlaces': 'Lieux', 'trip.toast.placeUpdated': 'Lieu mis à jour', 'trip.toast.placeAdded': 'Lieu ajouté', 'trip.toast.placeDeleted': 'Lieu supprimé', 'trip.toast.selectDay': 'Veuillez d\'abord sélectionner un jour', - 'trip.toast.assignedToDay': 'Lieu assigné au jour', + 'trip.toast.assignedToDay': 'Lieu attribué au planning', 'trip.toast.reorderError': 'Échec de la réorganisation', 'trip.toast.reservationUpdated': 'Réservation mise à jour', 'trip.toast.reservationAdded': 'Réservation ajoutée', @@ -605,7 +605,7 @@ const fr: Record = { 'dayplan.totalCost': 'Coût total', 'dayplan.days': 'Jours', 'dayplan.dayN': 'Jour {n}', - 'dayplan.calculating': 'Calcul en cours...', + 'dayplan.calculating': 'Calcul en cours…', 'dayplan.route': 'Itinéraire', 'dayplan.optimize': 'Optimiser', 'dayplan.optimized': 'Itinéraire optimisé', @@ -620,11 +620,11 @@ const fr: Record = { 'dayplan.pdfError': 'Échec de l\'export PDF', // Places Sidebar - 'places.addPlace': 'Ajouter un lieu/activité', + 'places.addPlace': 'Ajouter un lieu ou une activité', 'places.assignToDay': 'Ajouter à quel jour ?', 'places.all': 'Tous', 'places.unplanned': 'Non planifiés', - 'places.search': 'Rechercher des lieux...', + 'places.search': 'Rechercher des lieux…', 'places.allCategories': 'Toutes les catégories', 'places.count': '{count} lieux', 'places.countSingular': '1 lieu', @@ -634,7 +634,7 @@ const fr: Record = { 'places.formName': 'Nom', 'places.formNamePlaceholder': 'ex. Tour Eiffel', 'places.formDescription': 'Description', - 'places.formDescriptionPlaceholder': 'Brève description...', + 'places.formDescriptionPlaceholder': 'Brève description…', 'places.formAddress': 'Adresse', 'places.formAddressPlaceholder': 'Rue, ville, pays', 'places.formLat': 'Latitude (ex. 48.8566)', @@ -648,10 +648,10 @@ const fr: Record = { 'places.endTimeBeforeStart': 'L\'heure de fin est antérieure à l\'heure de début', 'places.timeCollision': 'Chevauchement horaire avec :', 'places.formWebsite': 'Site web', - 'places.formNotesPlaceholder': 'Notes personnelles...', + 'places.formNotesPlaceholder': 'Notes personnelles…', 'places.formReservation': 'Réservation', - 'places.reservationNotesPlaceholder': 'Notes de réservation, numéro de confirmation...', - 'places.mapsSearchPlaceholder': 'Rechercher des lieux...', + 'places.reservationNotesPlaceholder': 'Notes de réservation, numéro de confirmation…', + 'places.mapsSearchPlaceholder': 'Rechercher des lieux…', 'places.mapsSearchError': 'La recherche de lieu a échoué.', 'places.osmHint': 'Recherche via OpenStreetMap (pas de photos, horaires ni notes). Ajoutez une clé API Google dans les paramètres pour plus de détails.', 'places.osmActive': 'Recherche via OpenStreetMap (pas de photos, notes ni horaires). Ajoutez une clé API Google dans les paramètres pour des données enrichies.', @@ -696,7 +696,7 @@ const fr: Record = { 'reservations.time': 'Heure', 'reservations.timeAlt': 'Heure (alternative, ex. 19h30)', 'reservations.notes': 'Notes', - 'reservations.notesPlaceholder': 'Notes supplémentaires...', + 'reservations.notesPlaceholder': 'Notes supplémentaires…', 'reservations.meta.airline': 'Compagnie aérienne', 'reservations.meta.flightNumber': 'N° de vol', 'reservations.meta.from': 'De', @@ -726,14 +726,14 @@ const fr: Record = { 'reservations.confirm.delete': 'Voulez-vous vraiment supprimer la réservation « {name} » ?', 'reservations.toast.updated': 'Réservation mise à jour', 'reservations.toast.removed': 'Réservation supprimée', - 'reservations.toast.fileUploaded': 'Fichier téléversé', - 'reservations.toast.uploadError': 'Échec du téléversement', + 'reservations.toast.fileUploaded': 'Fichier importé', + 'reservations.toast.uploadError': 'Échec de l\'import', 'reservations.newTitle': 'Nouvelle réservation', 'reservations.bookingType': 'Type de réservation', 'reservations.titleLabel': 'Titre', - 'reservations.titlePlaceholder': 'ex. Lufthansa LH123, Hôtel Adlon, ...', + 'reservations.titlePlaceholder': 'ex. Lufthansa LH123, Hôtel Adlon, …', 'reservations.locationAddress': 'Lieu / Adresse', - 'reservations.locationPlaceholder': 'Adresse, aéroport, hôtel...', + 'reservations.locationPlaceholder': 'Adresse, aéroport, hôtel…', 'reservations.confirmationCode': 'Code de réservation', 'reservations.confirmationPlaceholder': 'ex. ABC12345', 'reservations.day': 'Jour', @@ -741,22 +741,22 @@ const fr: Record = { 'reservations.place': 'Lieu', 'reservations.noPlace': 'Aucun lieu', 'reservations.pendingSave': 'sera enregistré…', - 'reservations.uploading': 'Téléversement...', + 'reservations.uploading': 'Importation…', 'reservations.attachFile': 'Joindre un fichier', 'reservations.linkExisting': 'Lier un fichier existant', 'reservations.toast.saveError': 'Échec de l\'enregistrement', 'reservations.toast.updateError': 'Échec de la mise à jour', 'reservations.toast.deleteError': 'Échec de la suppression', 'reservations.confirm.remove': 'Supprimer la réservation pour « {name} » ?', - 'reservations.linkAssignment': 'Lier à l\'assignation du jour', - 'reservations.pickAssignment': 'Sélectionnez une assignation de votre plan...', + 'reservations.linkAssignment': 'Lier à l\'affectation du jour', + 'reservations.pickAssignment': 'Sélectionnez une affectation de votre plan…', 'reservations.noAssignment': 'Aucun lien (autonome)', // Budget 'budget.title': 'Budget', 'budget.emptyTitle': 'Aucun budget créé', 'budget.emptyText': 'Créez des catégories et des entrées pour planifier votre budget de voyage', - 'budget.emptyPlaceholder': 'Nom de la catégorie...', + 'budget.emptyPlaceholder': 'Nom de la catégorie…', 'budget.createCategory': 'Créer une catégorie', 'budget.category': 'Catégorie', 'budget.categoryName': 'Nom de la catégorie', @@ -774,24 +774,24 @@ const fr: Record = { 'budget.total': 'Total', 'budget.totalBudget': 'Budget total', 'budget.byCategory': 'Par catégorie', - 'budget.editTooltip': 'Cliquer pour modifier', + 'budget.editTooltip': 'Cliquez pour modifier', 'budget.confirm.deleteCategory': 'Voulez-vous vraiment supprimer la catégorie « {name} » avec {count} entrées ?', 'budget.deleteCategory': 'Supprimer la catégorie', 'budget.perPerson': 'Par personne', 'budget.paid': 'Payé', 'budget.open': 'Ouvert', - 'budget.noMembers': 'Aucun membre assigné', + 'budget.noMembers': 'Aucun membre attribué', // Files 'files.title': 'Fichiers', 'files.count': '{count} fichiers', 'files.countSingular': '1 fichier', - 'files.uploaded': '{count} téléversés', - 'files.uploadError': 'Échec du téléversement', + 'files.uploaded': '{count} importés', + 'files.uploadError': 'Échec de l\'import', 'files.dropzone': 'Déposez les fichiers ici', 'files.dropzoneHint': 'ou cliquez pour parcourir', 'files.allowedTypes': 'Images, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 Mo', - 'files.uploading': 'Téléversement...', + 'files.uploading': 'Importation…', 'files.filterAll': 'Tous', 'files.filterPdf': 'PDF', 'files.filterImages': 'Images', @@ -799,7 +799,7 @@ const fr: Record = { 'files.filterCollab': 'Notes Collab', 'files.sourceCollab': 'Depuis les notes Collab', 'files.empty': 'Aucun fichier', - 'files.emptyHint': 'Téléversez des fichiers pour les joindre à votre voyage', + 'files.emptyHint': 'Importez des fichiers pour les joindre à votre voyage', 'files.openTab': 'Ouvrir dans un nouvel onglet', 'files.confirm.delete': 'Voulez-vous vraiment supprimer ce fichier ?', 'files.toast.deleted': 'Fichier supprimé', @@ -818,18 +818,18 @@ const fr: Record = { 'files.assignTitle': 'Assigner le fichier', 'files.assignPlace': 'Lieu', 'files.assignBooking': 'Réservation', - 'files.unassigned': 'Non assigné', + 'files.unassigned': 'Non attribué', 'files.unlink': 'Supprimer le lien', 'files.toast.trashed': 'Déplacé dans la corbeille', 'files.toast.restored': 'Fichier restauré', 'files.toast.trashEmptied': 'Corbeille vidée', - 'files.toast.assigned': 'Fichier assigné', + 'files.toast.assigned': 'Fichier attribué', 'files.toast.assignError': 'Échec de l\'assignation', 'files.toast.restoreError': 'Échec de la restauration', 'files.confirm.permanentDelete': 'Supprimer définitivement ce fichier ? Cette action est irréversible.', 'files.confirm.emptyTrash': 'Supprimer définitivement tous les fichiers de la corbeille ? Cette action est irréversible.', 'files.noteLabel': 'Note', - 'files.notePlaceholder': 'Ajouter une note...', + 'files.notePlaceholder': 'Ajouter une note…', // Packing 'packing.title': 'Liste de bagages', @@ -841,8 +841,8 @@ const fr: Record = { 'packing.suggestionsTitle': 'Ajouter des suggestions', 'packing.allSuggested': 'Toutes les suggestions ajoutées', 'packing.allPacked': 'Tout est emballé !', - 'packing.addPlaceholder': 'Ajouter un nouvel article...', - 'packing.categoryPlaceholder': 'Catégorie...', + 'packing.addPlaceholder': 'Ajouter un nouvel article…', + 'packing.categoryPlaceholder': 'Catégorie…', 'packing.filterAll': 'Tous', 'packing.filterOpen': 'À faire', 'packing.filterDone': 'Fait', @@ -956,10 +956,10 @@ const fr: Record = { // Backup (Admin) 'backup.title': 'Sauvegarde des données', - 'backup.subtitle': 'Base de données et tous les fichiers téléversés', + 'backup.subtitle': 'Base de données et tous les fichiers importés', 'backup.refresh': 'Actualiser', - 'backup.upload': 'Téléverser une sauvegarde', - 'backup.uploading': 'Téléversement…', + 'backup.upload': 'Importer une sauvegarde', + 'backup.uploading': 'Importation…', 'backup.create': 'Créer une sauvegarde', 'backup.creating': 'Création…', 'backup.empty': 'Aucune sauvegarde', @@ -967,14 +967,14 @@ const fr: Record = { 'backup.download': 'Télécharger', 'backup.restore': 'Restaurer', 'backup.confirm.restore': 'Restaurer la sauvegarde « {name} » ?\n\nToutes les données actuelles seront remplacées par la sauvegarde.', - 'backup.confirm.uploadRestore': 'Téléverser et restaurer le fichier de sauvegarde « {name} » ?\n\nToutes les données actuelles seront écrasées.', + 'backup.confirm.uploadRestore': 'Importer et restaurer le fichier de sauvegarde « {name} » ?\n\nToutes les données actuelles seront écrasées.', 'backup.confirm.delete': 'Supprimer la sauvegarde « {name} » ?', 'backup.toast.loadError': 'Impossible de charger les sauvegardes', 'backup.toast.created': 'Sauvegarde créée avec succès', 'backup.toast.createError': 'Impossible de créer la sauvegarde', 'backup.toast.restored': 'Sauvegarde restaurée. La page va se recharger…', 'backup.toast.restoreError': 'Échec de la restauration', - 'backup.toast.uploadError': 'Échec du téléversement', + 'backup.toast.uploadError': 'Échec de l\'import', 'backup.toast.deleted': 'Sauvegarde supprimée', 'backup.toast.deleteError': 'Échec de la suppression', 'backup.toast.downloadError': 'Échec du téléchargement', @@ -1000,15 +1000,15 @@ const fr: Record = { // Photos 'photos.allDays': 'Tous les jours', 'photos.noPhotos': 'Aucune photo', - 'photos.uploadHint': 'Téléversez vos photos de voyage', + 'photos.uploadHint': 'Importez vos photos de voyage', 'photos.clickToSelect': 'ou cliquez pour sélectionner', 'photos.linkPlace': 'Lier au lieu', 'photos.noPlace': 'Aucun lieu', - 'photos.uploadN': '{n} photo(s) téléversées', + 'photos.uploadN': '{n} photo(s) importée(s)', // Backup restore modal 'backup.restoreConfirmTitle': 'Restaurer la sauvegarde ?', - 'backup.restoreWarning': 'Toutes les données actuelles (voyages, lieux, utilisateurs, téléversements) seront définitivement remplacées par la sauvegarde. Cette action est irréversible.', + 'backup.restoreWarning': 'Toutes les données actuelles (voyages, lieux, utilisateurs, importations) seront définitivement remplacées par la sauvegarde. Cette action est irréversible.', 'backup.restoreTip': 'Conseil : créez une sauvegarde de l\'état actuel avant de restaurer.', 'backup.restoreConfirm': 'Oui, restaurer', @@ -1045,8 +1045,8 @@ const fr: Record = { 'planner.placeN': '{n} lieux', 'planner.addNote': 'Ajouter une note', 'planner.noEntries': 'Aucune entrée pour ce jour', - 'planner.addPlace': 'Ajouter un lieu/activité', - 'planner.addPlaceShort': '+ Ajouter un lieu/activité', + 'planner.addPlace': 'Ajouter un lieu ou une activité', + 'planner.addPlaceShort': '+ Ajouter un lieu ou une activité', 'planner.resPending': 'Réservation en attente · ', 'planner.resConfirmed': 'Réservation confirmée · ', 'planner.notePlaceholder': 'Note…', @@ -1076,7 +1076,7 @@ const fr: Record = { 'planner.noDays': 'Aucun jour', 'planner.editTripToAddDays': 'Modifiez le voyage pour ajouter des jours', 'planner.dayCount': '{n} jours', - 'planner.clickToUnlock': 'Cliquer pour déverrouiller', + 'planner.clickToUnlock': 'Cliquez pour déverrouiller', 'planner.keepPosition': 'Maintenir la position lors de l\'optimisation de l\'itinéraire', 'planner.dayDetails': 'Détails du jour', 'planner.dayN': 'Jour {n}', @@ -1139,7 +1139,7 @@ const fr: Record = { 'memories.allLocations': 'Tous les lieux', // Collab Addon - 'collab.tabs.chat': 'Chat', + 'collab.tabs.chat': 'Discussion', 'collab.tabs.notes': 'Notes', 'collab.tabs.polls': 'Sondages', 'collab.whatsNext.title': 'À venir', @@ -1149,7 +1149,7 @@ const fr: Record = { 'collab.whatsNext.until': 'à', 'collab.whatsNext.emptyHint': 'Les activités avec des horaires apparaîtront ici', 'collab.chat.send': 'Envoyer', - 'collab.chat.placeholder': 'Écrire un message...', + 'collab.chat.placeholder': 'Écrire un message…', 'collab.chat.empty': 'Commencez la conversation', 'collab.chat.emptyHint': 'Les messages sont partagés avec tous les membres du voyage', 'collab.chat.emptyDesc': 'Partagez des idées, des plans et des mises à jour avec votre groupe de voyage', @@ -1166,9 +1166,9 @@ const fr: Record = { 'collab.notes.emptyHint': 'Commencez à capturer vos idées et plans', 'collab.notes.all': 'Toutes', 'collab.notes.titlePlaceholder': 'Titre de la note', - 'collab.notes.contentPlaceholder': 'Écrivez quelque chose...', + 'collab.notes.contentPlaceholder': 'Écrivez quelque chose…', 'collab.notes.categoryPlaceholder': 'Catégorie', - 'collab.notes.newCategory': 'Nouvelle catégorie...', + 'collab.notes.newCategory': 'Nouvelle catégorie…', 'collab.notes.category': 'Catégorie', 'collab.notes.noCategory': 'Sans catégorie', 'collab.notes.color': 'Couleur', @@ -1182,7 +1182,7 @@ const fr: Record = { 'collab.notes.categorySettings': 'Gérer les catégories', 'collab.notes.create': 'Créer', 'collab.notes.website': 'Site web', - 'collab.notes.websitePlaceholder': 'https://...', + 'collab.notes.websitePlaceholder': 'https://…', 'collab.notes.attachFiles': 'Joindre des fichiers', 'collab.notes.noCategoriesYet': 'Aucune catégorie', 'collab.notes.emptyDesc': 'Créez une note pour commencer', From 12f8b6eb5522183d15ec135b3e1027da0c647350 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 29 Mar 2026 23:38:51 +0200 Subject: [PATCH 2/7] feat: add missing French translation keys for memories and weekend days --- client/src/i18n/translations/fr.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index f610cc0..28a7462 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -473,6 +473,14 @@ const fr: Record = { 'vacay.used': 'Utilisés', 'vacay.remaining': 'Restants', 'vacay.carriedOver': 'de {year}', + 'vacay.weekendDays': 'Jours de week-end', + 'vacay.mon': 'Lun', + 'vacay.tue': 'Mar', + 'vacay.wed': 'Mer', + 'vacay.thu': 'Jeu', + 'vacay.fri': 'Ven', + 'vacay.sat': 'Sam', + 'vacay.sun': 'Dim', 'vacay.blockWeekends': 'Bloquer les week-ends', 'vacay.blockWeekendsHint': 'Empêcher les entrées de vacances les samedis et dimanches', 'vacay.publicHolidays': 'Jours fériés', @@ -1137,6 +1145,19 @@ const fr: Record = { 'memories.oldest': 'Plus anciennes', 'memories.newest': 'Plus récentes', 'memories.allLocations': 'Tous les lieux', + 'memories.addPhotos': 'Ajouter des photos', + 'memories.selectPhotos': 'Sélectionner des photos depuis Immich', + 'memories.selectHint': 'Appuyez sur les photos pour les sélectionner.', + 'memories.selected': 'sélectionnées', + 'memories.addSelected': 'Ajouter {count} photos', + 'memories.alreadyAdded': 'Déjà ajoutée', + 'memories.private': 'Privé', + 'memories.stopSharing': 'Arrêter le partage', + 'memories.tripDates': 'Dates du voyage', + 'memories.allPhotos': 'Toutes les photos', + 'memories.confirmShareTitle': 'Partager avec les membres du voyage ?', + 'memories.confirmShareHint': '{count} photos seront visibles par tous les membres de ce voyage. Vous pourrez rendre des photos privées ultérieurement.', + 'memories.confirmShareButton': 'Partager les photos', // Collab Addon 'collab.tabs.chat': 'Discussion', From 8bca921b3064216ee1094f9af9992f3dbdeacbab Mon Sep 17 00:00:00 2001 From: AxelFl Date: Sun, 29 Mar 2026 23:42:11 +0200 Subject: [PATCH 3/7] =?UTF-8?q?docs:=20fix=20docker=20image=20name=20in=20?= =?UTF-8?q?SECURITY.md=20=E2=80=94=20thanks=20@AxelFl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 5d0d2e0..eba8714 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -21,6 +21,6 @@ You will receive a response within 48 hours. Once confirmed, a fix will be relea ## Scope -This policy covers the TREK application and its Docker image (`mauriceboe/nomad`). +This policy covers the TREK application and its Docker image (`mauriceboe/trek`). Third-party dependencies are monitored via GitHub Dependabot. From 42ebc7c2988c4ed8f13f2501935eb614961b13cd Mon Sep 17 00:00:00 2001 From: Fabian Sievert Date: Sun, 29 Mar 2026 23:44:20 +0200 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20add=20Helm=20chart=20for=20Kubernet?= =?UTF-8?q?es=20deployment=20=E2=80=94=20thanks=20@another-novelty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add basic helm chart * Delete chart/my-values.yaml --- chart/Chart.yaml | 5 +++ chart/README.md | 33 ++++++++++++++++++ chart/templates/NOTES.txt | 13 +++++++ chart/templates/_helpers.tpl | 18 ++++++++++ chart/templates/configmap.yaml | 12 +++++++ chart/templates/deployment.yaml | 61 +++++++++++++++++++++++++++++++++ chart/templates/ingress.yaml | 32 +++++++++++++++++ chart/templates/pvc.yaml | 25 ++++++++++++++ chart/templates/secret.yaml | 23 +++++++++++++ chart/templates/service.yaml | 15 ++++++++ chart/values.yaml | 53 ++++++++++++++++++++++++++++ 11 files changed, 290 insertions(+) create mode 100644 chart/Chart.yaml create mode 100644 chart/README.md create mode 100644 chart/templates/NOTES.txt create mode 100644 chart/templates/_helpers.tpl create mode 100644 chart/templates/configmap.yaml create mode 100644 chart/templates/deployment.yaml create mode 100644 chart/templates/ingress.yaml create mode 100644 chart/templates/pvc.yaml create mode 100644 chart/templates/secret.yaml create mode 100644 chart/templates/service.yaml create mode 100644 chart/values.yaml diff --git a/chart/Chart.yaml b/chart/Chart.yaml new file mode 100644 index 0000000..886ba48 --- /dev/null +++ b/chart/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: trek +version: 0.1.0 +description: Minimal Helm chart for TREK app +appVersion: "latest" diff --git a/chart/README.md b/chart/README.md new file mode 100644 index 0000000..c5689b9 --- /dev/null +++ b/chart/README.md @@ -0,0 +1,33 @@ +# TREK Helm Chart + +This is a minimal Helm chart for deploying the TREK app. + +## Features +- Deploys the TREK container +- Exposes port 3000 via Service +- Optional persistent storage for `/app/data` and `/app/uploads` +- Configurable environment variables and secrets +- Optional generic Ingress support +- Health checks on `/api/health` + +## Usage + +```sh +helm install trek ./chart \ + --set secretEnv.JWT_SECRET=your_jwt_secret \ + --set ingress.enabled=true \ + --set ingress.hosts[0].host=yourdomain.com +``` + +See `values.yaml` for more options. + +## Files +- `Chart.yaml` — chart metadata +- `values.yaml` — configuration values +- `templates/` — Kubernetes manifests + +## Notes +- Ingress is off by default. Enable and configure hosts for your domain. +- PVCs require a default StorageClass or specify one as needed. +- JWT_SECRET must be set for production use. +- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically. diff --git a/chart/templates/NOTES.txt b/chart/templates/NOTES.txt new file mode 100644 index 0000000..45a1993 --- /dev/null +++ b/chart/templates/NOTES.txt @@ -0,0 +1,13 @@ +1. JWT_SECRET handling: + - By default, the chart creates a secret with the value from `values.yaml: secretEnv.JWT_SECRET`. + - To generate a random JWT_SECRET at install, set `generateJwtSecret: true`. + - To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must have a key matching `existingSecretKey` (defaults to `JWT_SECRET`). + +2. Example usage: + - Set a custom secret: `--set secretEnv.JWT_SECRET=your_secret` + - Generate a random secret: `--set generateJwtSecret=true` + - Use an existing secret: `--set existingSecret=my-k8s-secret` + - Use a custom key in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_KEY` + +3. Only one method should be used at a time. If both `generateJwtSecret` and `existingSecret` are set, `existingSecret` takes precedence. + If using `existingSecret`, ensure the referenced secret and key exist in the target namespace. diff --git a/chart/templates/_helpers.tpl b/chart/templates/_helpers.tpl new file mode 100644 index 0000000..a3089d7 --- /dev/null +++ b/chart/templates/_helpers.tpl @@ -0,0 +1,18 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "trek.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "trek.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s" $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml new file mode 100644 index 0000000..7a7ed6a --- /dev/null +++ b/chart/templates/configmap.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "trek.fullname" . }}-config + labels: + app: {{ include "trek.name" . }} +data: + NODE_ENV: {{ .Values.env.NODE_ENV | quote }} + PORT: {{ .Values.env.PORT | quote }} + {{- if .Values.env.ALLOWED_ORIGINS }} + ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }} + {{- end }} diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml new file mode 100644 index 0000000..d10957e --- /dev/null +++ b/chart/templates/deployment.yaml @@ -0,0 +1,61 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "trek.fullname" . }} + labels: + app: {{ include "trek.name" . }} +spec: + replicas: 1 + selector: + matchLabels: + app: {{ include "trek.name" . }} + template: + metadata: + labels: + app: {{ include "trek.name" . }} + spec: + {{- if .Values.imagePullSecrets }} + imagePullSecrets: + {{- range .Values.imagePullSecrets }} + - name: {{ .name }} + {{- end }} + {{- end }} + containers: + - name: trek + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - containerPort: 3000 + envFrom: + - configMapRef: + name: {{ include "trek.fullname" . }}-config + env: + - name: JWT_SECRET + valueFrom: + secretKeyRef: + name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} + key: {{ .Values.existingSecretKey | default "JWT_SECRET" }} + volumeMounts: + - name: data + mountPath: /app/data + - name: uploads + mountPath: /app/uploads + livenessProbe: + httpGet: + path: /api/health + port: 3000 + initialDelaySeconds: 15 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /api/health + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 10 + volumes: + - name: data + persistentVolumeClaim: + claimName: {{ include "trek.fullname" . }}-data + - name: uploads + persistentVolumeClaim: + claimName: {{ include "trek.fullname" . }}-uploads diff --git a/chart/templates/ingress.yaml b/chart/templates/ingress.yaml new file mode 100644 index 0000000..a13b7f4 --- /dev/null +++ b/chart/templates/ingress.yaml @@ -0,0 +1,32 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "trek.fullname" . }} + labels: + app: {{ include "trek.name" . }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.tls }} + tls: + {{- toYaml .Values.ingress.tls | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host }} + http: + paths: + {{- range .paths }} + - path: {{ . }} + pathType: Prefix + backend: + service: + name: {{ include "trek.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/chart/templates/pvc.yaml b/chart/templates/pvc.yaml new file mode 100644 index 0000000..663bff5 --- /dev/null +++ b/chart/templates/pvc.yaml @@ -0,0 +1,25 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "trek.fullname" . }}-data + labels: + app: {{ include "trek.name" . }} +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ .Values.persistence.data.size }} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "trek.fullname" . }}-uploads + labels: + app: {{ include "trek.name" . }} +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ .Values.persistence.uploads.size }} diff --git a/chart/templates/secret.yaml b/chart/templates/secret.yaml new file mode 100644 index 0000000..b27596a --- /dev/null +++ b/chart/templates/secret.yaml @@ -0,0 +1,23 @@ +{{- if and (not .Values.existingSecret) (not .Values.generateJwtSecret) }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "trek.fullname" . }}-secret + labels: + app: {{ include "trek.name" . }} +type: Opaque +data: + {{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ .Values.secretEnv.JWT_SECRET | b64enc | quote }} +{{- end }} + +{{- if and (not .Values.existingSecret) (.Values.generateJwtSecret) }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "trek.fullname" . }}-secret + labels: + app: {{ include "trek.name" . }} +type: Opaque +stringData: + {{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ randAlphaNum 32 }} +{{- end }} diff --git a/chart/templates/service.yaml b/chart/templates/service.yaml new file mode 100644 index 0000000..f63e56f --- /dev/null +++ b/chart/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "trek.fullname" . }} + labels: + app: {{ include "trek.name" . }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: 3000 + protocol: TCP + name: http + selector: + app: {{ include "trek.name" . }} diff --git a/chart/values.yaml b/chart/values.yaml new file mode 100644 index 0000000..f52f3de --- /dev/null +++ b/chart/values.yaml @@ -0,0 +1,53 @@ + +image: + repository: mauriceboe/trek + tag: latest + pullPolicy: IfNotPresent + +# Optional image pull secrets for private registries +imagePullSecrets: [] + # - name: my-registry-secret + +service: + type: ClusterIP + port: 3000 + +env: + NODE_ENV: production + PORT: 3000 + # ALLOWED_ORIGINS: "" +# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration. + + +# JWT secret configuration +secretEnv: + # If set, use this value for JWT_SECRET (base64-encoded in secret.yaml) + JWT_SECRET: "" + +# If true, a random JWT_SECRET will be generated during install (overrides secretEnv.JWT_SECRET) +generateJwtSecret: false + +# If set, use an existing Kubernetes secret for JWT_SECRET +existingSecret: "" +existingSecretKey: JWT_SECRET + +persistence: + enabled: true + data: + size: 1Gi + uploads: + size: 1Gi + +resources: {} + +ingress: + enabled: false + annotations: {} + hosts: + - host: chart-example.local + paths: + - / + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local From 6444b2b4ce4cf65235a284a7652134dce5d48c05 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 29 Mar 2026 23:55:46 +0200 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20add=20Brazilian=20Portuguese=20(pt-?= =?UTF-8?q?BR)=20language=20support=20=E2=80=94=20thanks=20@fgbona?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/Vacay/VacayMonthCard.tsx | 6 +- client/src/i18n/TranslationContext.tsx | 7 +- client/src/i18n/translations/br.ts | 1233 +++++++++++++++++ 3 files changed, 1242 insertions(+), 4 deletions(-) create mode 100644 client/src/i18n/translations/br.ts diff --git a/client/src/components/Vacay/VacayMonthCard.tsx b/client/src/components/Vacay/VacayMonthCard.tsx index 708acb9..cedf7d9 100644 --- a/client/src/components/Vacay/VacayMonthCard.tsx +++ b/client/src/components/Vacay/VacayMonthCard.tsx @@ -7,12 +7,14 @@ const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'] const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] const WEEKDAYS_ES = ['Lu', 'Ma', 'Mi', 'Ju', 'Vi', 'Sa', 'Do'] const WEEKDAYS_FR = ['Lu', 'Ma', 'Me', 'Je', 'Ve', 'Sa', 'Di'] +const WEEKDAYS_BR = ['Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb', 'Dom'] const WEEKDAYS_AR = ['اث', 'ثل', 'أر', 'خم', 'جم', 'سب', 'أح'] const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'] const MONTHS_ES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'] const MONTHS_FR = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'] +const MONTHS_BR = ['Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro'] const MONTHS_AR = ['يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر'] function hexToRgba(hex: string, alpha: number): string { @@ -41,8 +43,8 @@ export default function VacayMonthCard({ }: VacayMonthCardProps) { const { language } = useTranslation() - const weekdays = language === 'de' ? WEEKDAYS_DE : language === 'es' ? WEEKDAYS_ES : language === 'fr' ? WEEKDAYS_FR : language === 'ar' ? WEEKDAYS_AR : WEEKDAYS_EN - const monthNames = language === 'de' ? MONTHS_DE : language === 'es' ? MONTHS_ES : language === 'fr' ? MONTHS_FR : language === 'ar' ? MONTHS_AR : MONTHS_EN + const weekdays = language === 'de' ? WEEKDAYS_DE : language === 'es' ? WEEKDAYS_ES : language === 'fr' ? WEEKDAYS_FR : language === 'br' ? WEEKDAYS_BR : language === 'ar' ? WEEKDAYS_AR : WEEKDAYS_EN + const monthNames = language === 'de' ? MONTHS_DE : language === 'es' ? MONTHS_ES : language === 'fr' ? MONTHS_FR : language === 'br' ? MONTHS_BR : language === 'ar' ? MONTHS_AR : MONTHS_EN const weeks = useMemo(() => { const firstDay = new Date(year, month, 1) diff --git a/client/src/i18n/TranslationContext.tsx b/client/src/i18n/TranslationContext.tsx index 97f3df1..64dd34c 100644 --- a/client/src/i18n/TranslationContext.tsx +++ b/client/src/i18n/TranslationContext.tsx @@ -8,6 +8,7 @@ import ru from './translations/ru' import zh from './translations/zh' import nl from './translations/nl' import ar from './translations/ar' +import br from './translations/br' type TranslationStrings = Record @@ -17,13 +18,14 @@ export const SUPPORTED_LANGUAGES = [ { value: 'es', label: 'Español' }, { value: 'fr', label: 'Français' }, { value: 'nl', label: 'Nederlands' }, + { value: 'br', label: 'Português (Brasil)' }, { value: 'ru', label: 'Русский' }, { value: 'zh', label: '中文' }, { value: 'ar', label: 'العربية' }, ] as const -const translations: Record = { de, en, es, fr, ru, zh, nl, ar } -const LOCALES: Record = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', ru: 'ru-RU', zh: 'zh-CN', nl: 'nl-NL', ar: 'ar-SA' } +const translations: Record = { de, en, es, fr, ru, zh, nl, ar, br } +const LOCALES: Record = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', ru: 'ru-RU', zh: 'zh-CN', nl: 'nl-NL', ar: 'ar-SA', br: 'pt-BR' } const RTL_LANGUAGES = new Set(['ar']) export function getLocaleForLanguage(language: string): string { @@ -31,6 +33,7 @@ export function getLocaleForLanguage(language: string): string { } export function getIntlLanguage(language: string): string { + if (language === 'br') return 'pt-BR' return ['de', 'es', 'fr', 'ru', 'zh', 'nl', 'ar'].includes(language) ? language : 'en' } diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts new file mode 100644 index 0000000..969e8a0 --- /dev/null +++ b/client/src/i18n/translations/br.ts @@ -0,0 +1,1233 @@ +const br: Record = { + // Common + 'common.save': 'Salvar', + 'common.cancel': 'Cancelar', + 'common.delete': 'Excluir', + 'common.edit': 'Editar', + 'common.add': 'Adicionar', + 'common.loading': 'Carregando...', + 'common.error': 'Erro', + 'common.back': 'Voltar', + 'common.all': 'Todos', + 'common.close': 'Fechar', + 'common.open': 'Abrir', + 'common.upload': 'Enviar', + 'common.search': 'Buscar', + 'common.confirm': 'Confirmar', + 'common.ok': 'OK', + 'common.yes': 'Sim', + 'common.no': 'Não', + 'common.or': 'ou', + 'common.none': 'Nenhum', + 'common.date': 'Data', + 'common.rename': 'Renomear', + 'common.name': 'Nome', + 'common.email': 'E-mail', + 'common.password': 'Senha', + 'common.saving': 'Salvando...', + 'common.update': 'Atualizar', + 'common.change': 'Alterar', + 'common.uploading': 'Enviando…', + 'common.backToPlanning': 'Voltar ao planejamento', + 'common.reset': 'Redefinir', + + // Navbar + 'nav.trip': 'Viagem', + 'nav.share': 'Compartilhar', + 'nav.settings': 'Configurações', + 'nav.admin': 'Admin', + 'nav.logout': 'Sair', + 'nav.lightMode': 'Modo claro', + 'nav.darkMode': 'Modo escuro', + 'nav.autoMode': 'Automático', + 'nav.administrator': 'Administrador', + + // Dashboard + 'dashboard.title': 'Minhas viagens', + 'dashboard.subtitle.loading': 'Carregando viagens...', + 'dashboard.subtitle.trips': '{count} viagens ({archived} arquivadas)', + 'dashboard.subtitle.empty': 'Comece sua primeira viagem', + 'dashboard.subtitle.activeOne': '{count} viagem ativa', + 'dashboard.subtitle.activeMany': '{count} viagens ativas', + 'dashboard.subtitle.archivedSuffix': ' · {count} arquivadas', + 'dashboard.newTrip': 'Nova viagem', + 'dashboard.gridView': 'Grade', + 'dashboard.listView': 'Lista', + 'dashboard.currency': 'Moeda', + 'dashboard.timezone': 'Fusos horários', + 'dashboard.localTime': 'Local', + 'dashboard.timezoneCustomTitle': 'Fuso personalizado', + 'dashboard.timezoneCustomLabelPlaceholder': 'Rótulo (opcional)', + 'dashboard.timezoneCustomTzPlaceholder': 'ex.: America/Sao_Paulo', + 'dashboard.timezoneCustomAdd': 'Adicionar', + 'dashboard.timezoneCustomErrorEmpty': 'Informe um identificador de fuso', + 'dashboard.timezoneCustomErrorInvalid': 'Fuso inválido. Use o formato Europe/Berlin', + 'dashboard.timezoneCustomErrorDuplicate': 'Já adicionado', + 'dashboard.emptyTitle': 'Nenhuma viagem ainda', + 'dashboard.emptyText': 'Crie sua primeira viagem e comece a planejar!', + 'dashboard.emptyButton': 'Criar primeira viagem', + 'dashboard.nextTrip': 'Próxima viagem', + 'dashboard.shared': 'Compartilhada', + 'dashboard.sharedBy': 'Compartilhada por {name}', + 'dashboard.days': 'Dias', + 'dashboard.places': 'Lugares', + 'dashboard.archive': 'Arquivar', + 'dashboard.restore': 'Restaurar', + 'dashboard.archived': 'Arquivada', + 'dashboard.status.ongoing': 'Em andamento', + 'dashboard.status.today': 'Hoje', + 'dashboard.status.tomorrow': 'Amanhã', + 'dashboard.status.past': 'Passada', + 'dashboard.status.daysLeft': 'Faltam {count} dias', + 'dashboard.toast.loadError': 'Não foi possível carregar as viagens', + 'dashboard.toast.created': 'Viagem criada com sucesso!', + 'dashboard.toast.createError': 'Não foi possível criar a viagem', + 'dashboard.toast.updated': 'Viagem atualizada!', + 'dashboard.toast.updateError': 'Não foi possível atualizar a viagem', + 'dashboard.toast.deleted': 'Viagem excluída', + 'dashboard.toast.deleteError': 'Não foi possível excluir a viagem', + 'dashboard.toast.archived': 'Viagem arquivada', + 'dashboard.toast.archiveError': 'Não foi possível arquivar', + 'dashboard.toast.restored': 'Viagem restaurada', + 'dashboard.toast.restoreError': 'Não foi possível restaurar', + 'dashboard.confirm.delete': 'Excluir a viagem "{title}"? Todos os lugares e planos serão excluídos permanentemente.', + 'dashboard.editTrip': 'Editar viagem', + 'dashboard.createTrip': 'Criar nova viagem', + 'dashboard.tripTitle': 'Título', + 'dashboard.tripTitlePlaceholder': 'ex.: Verão no Japão', + 'dashboard.tripDescription': 'Descrição', + 'dashboard.tripDescriptionPlaceholder': 'Sobre o que é esta viagem?', + 'dashboard.startDate': 'Data de início', + 'dashboard.endDate': 'Data de término', + 'dashboard.noDateHint': 'Sem datas — serão criados 7 dias padrão. Você pode alterar depois.', + 'dashboard.coverImage': 'Imagem de capa', + 'dashboard.addCoverImage': 'Adicionar capa (ou arrastar e soltar)', + 'dashboard.addMembers': 'Companheiros de viagem', + 'dashboard.addMember': 'Adicionar membro', + 'dashboard.coverSaved': 'Capa salva', + 'dashboard.coverUploadError': 'Falha no envio', + 'dashboard.coverRemoveError': 'Falha ao remover', + 'dashboard.titleRequired': 'O título é obrigatório', + 'dashboard.endDateError': 'A data final deve ser depois da inicial', + + // Settings + 'settings.title': 'Configurações', + 'settings.subtitle': 'Ajuste suas preferências pessoais', + 'settings.map': 'Mapa', + 'settings.mapTemplate': 'Modelo de mapa', + 'settings.mapTemplatePlaceholder.select': 'Selecione o modelo...', + 'settings.mapDefaultHint': 'Deixe vazio para OpenStreetMap (padrão)', + 'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + 'settings.mapHint': 'URL do modelo de blocos do mapa', + 'settings.latitude': 'Latitude', + 'settings.longitude': 'Longitude', + 'settings.saveMap': 'Salvar mapa', + 'settings.apiKeys': 'Chaves de API', + 'settings.mapsKey': 'Chave da API Google Maps', + 'settings.mapsKeyHint': 'Para busca de lugares. Requer Places API (New). Obtenha em console.cloud.google.com', + 'settings.weatherKey': 'Chave OpenWeatherMap', + 'settings.weatherKeyHint': 'Para dados meteorológicos. Grátis em openweathermap.org/api', + 'settings.keyPlaceholder': 'Digite a chave...', + 'settings.configured': 'Configurada', + 'settings.saveKeys': 'Salvar chaves', + 'settings.display': 'Exibição', + 'settings.colorMode': 'Tema de cores', + 'settings.light': 'Claro', + 'settings.dark': 'Escuro', + 'settings.auto': 'Automático', + 'settings.language': 'Idioma', + 'settings.temperature': 'Unidade de temperatura', + 'settings.timeFormat': 'Formato de hora', + 'settings.routeCalculation': 'Cálculo de rota', + 'settings.on': 'Ligado', + 'settings.off': 'Desligado', + 'settings.account': 'Conta', + 'settings.username': 'Nome de usuário', + 'settings.email': 'E-mail', + 'settings.role': 'Função', + 'settings.roleAdmin': 'Administrador', + 'settings.oidcLinked': 'Vinculado a', + 'settings.changePassword': 'Alterar senha', + 'settings.currentPassword': 'Senha atual', + 'settings.currentPasswordRequired': 'A senha atual é obrigatória', + 'settings.newPassword': 'Nova senha', + 'settings.confirmPassword': 'Confirmar nova senha', + 'settings.updatePassword': 'Atualizar senha', + 'settings.passwordRequired': 'Informe a senha atual e a nova', + 'settings.passwordTooShort': 'A senha deve ter pelo menos 8 caracteres', + 'settings.passwordMismatch': 'As senhas não coincidem', + 'settings.passwordWeak': 'A senha deve ter maiúscula, minúscula e número', + 'settings.passwordChanged': 'Senha alterada com sucesso', + 'settings.deleteAccount': 'Excluir conta', + 'settings.deleteAccountTitle': 'Excluir sua conta?', + 'settings.deleteAccountWarning': 'Sua conta e todas as viagens, lugares e arquivos serão excluídos permanentemente. Esta ação não pode ser desfeita.', + 'settings.deleteAccountConfirm': 'Excluir permanentemente', + 'settings.deleteBlockedTitle': 'Exclusão não permitida', + 'settings.deleteBlockedMessage': 'Você é o único administrador. Promova outro usuário a administrador antes de excluir sua conta.', + 'settings.roleUser': 'Usuário', + 'settings.saveProfile': 'Salvar perfil', + 'settings.toast.mapSaved': 'Configurações do mapa salvas', + 'settings.toast.keysSaved': 'Chaves de API salvas', + 'settings.toast.displaySaved': 'Configurações de exibição salvas', + 'settings.toast.profileSaved': 'Perfil salvo', + 'settings.uploadAvatar': 'Enviar foto do perfil', + 'settings.removeAvatar': 'Remover foto do perfil', + 'settings.avatarUploaded': 'Foto do perfil atualizada', + 'settings.avatarRemoved': 'Foto do perfil removida', + 'settings.avatarError': 'Falha no envio', + 'settings.mfa.title': 'Autenticação em duas etapas (2FA)', + 'settings.mfa.description': 'Adiciona uma segunda etapa ao entrar com e-mail e senha. Use um app autenticador (Google Authenticator, Authy, etc.).', + 'settings.mfa.enabled': 'O 2FA está ativado na sua conta.', + 'settings.mfa.disabled': 'O 2FA não está ativado.', + 'settings.mfa.setup': 'Configurar autenticador', + 'settings.mfa.scanQr': 'Leia este QR code no app ou digite o segredo manualmente.', + 'settings.mfa.secretLabel': 'Chave secreta (entrada manual)', + 'settings.mfa.codePlaceholder': 'Código de 6 dígitos', + 'settings.mfa.enable': 'Ativar 2FA', + 'settings.mfa.cancelSetup': 'Cancelar', + 'settings.mfa.disableTitle': 'Desativar 2FA', + 'settings.mfa.disableHint': 'Digite sua senha e um código atual do autenticador.', + 'settings.mfa.disable': 'Desativar 2FA', + 'settings.mfa.toastEnabled': 'Autenticação em duas etapas ativada', + 'settings.mfa.toastDisabled': 'Autenticação em duas etapas desativada', + 'settings.mfa.demoBlocked': 'Indisponível no modo demonstração', + + // Login + 'login.error': 'Falha no login. Verifique suas credenciais.', + 'login.tagline': 'Suas viagens.\nSeu plano.', + 'login.description': 'Planeje viagens em equipe com mapas interativos, orçamento e sincronização em tempo real.', + 'login.features.maps': 'Mapas interativos', + 'login.features.mapsDesc': 'Google Places, rotas e agrupamento', + 'login.features.realtime': 'Sincronização em tempo real', + 'login.features.realtimeDesc': 'Planejem juntos via WebSocket', + 'login.features.budget': 'Controle de orçamento', + 'login.features.budgetDesc': 'Categorias, gráficos e custo por pessoa', + 'login.features.collab': 'Colaboração', + 'login.features.collabDesc': 'Vários usuários com viagens compartilhadas', + 'login.features.packing': 'Listas de malas', + 'login.features.packingDesc': 'Categorias, progresso e sugestões', + 'login.features.bookings': 'Reservas', + 'login.features.bookingsDesc': 'Voos, hotéis, restaurantes e mais', + 'login.features.files': 'Documentos', + 'login.features.filesDesc': 'Envie e gerencie documentos', + 'login.features.routes': 'Rotas inteligentes', + 'login.features.routesDesc': 'Otimize e exporte para o Google Maps', + 'login.selfHosted': 'Auto-hospedado \u00B7 Código aberto \u00B7 Seus dados são seus', + 'login.title': 'Entrar', + 'login.subtitle': 'Bem-vindo de volta', + 'login.signingIn': 'Entrando…', + 'login.signIn': 'Entrar', + 'login.createAdmin': 'Criar conta de administrador', + 'login.createAdminHint': 'Configure a primeira conta de administrador do TREK.', + 'login.createAccount': 'Criar conta', + 'login.createAccountHint': 'Cadastre uma nova conta.', + 'login.creating': 'Criando…', + 'login.noAccount': 'Não tem conta?', + 'login.hasAccount': 'Já tem conta?', + 'login.register': 'Cadastrar', + 'login.emailPlaceholder': 'seu@email.com', + 'login.username': 'Nome de usuário', + 'login.oidc.registrationDisabled': 'Cadastro desativado. Fale com o administrador.', + 'login.oidc.noEmail': 'Nenhum e-mail recebido do provedor.', + 'login.oidc.tokenFailed': 'Falha na autenticação.', + 'login.oidc.invalidState': 'Sessão inválida. Tente novamente.', + 'login.demoFailed': 'Falha no login de demonstração', + 'login.oidcSignIn': 'Entrar com {name}', + 'login.oidcOnly': 'Login por senha desativado. Use o provedor SSO.', + 'login.demoHint': 'Experimente a demonstração — sem cadastro', + 'login.mfaTitle': 'Autenticação em duas etapas', + 'login.mfaSubtitle': 'Digite o código de 6 dígitos do seu app autenticador.', + 'login.mfaCodeLabel': 'Código de verificação', + 'login.mfaCodeRequired': 'Digite o código do app autenticador.', + 'login.mfaHint': 'Abra o Google Authenticator, Authy ou outro app TOTP.', + 'login.mfaBack': '← Voltar ao login', + 'login.mfaVerify': 'Verificar', + + // Register + 'register.passwordMismatch': 'As senhas não coincidem', + 'register.passwordTooShort': 'A senha deve ter pelo menos 6 caracteres', + 'register.failed': 'Falha no cadastro', + 'register.getStarted': 'Começar', + 'register.subtitle': 'Crie uma conta e comece a planejar suas viagens.', + 'register.feature1': 'Viagens ilimitadas', + 'register.feature2': 'Mapa interativo', + 'register.feature3': 'Gerencie lugares e categorias', + 'register.feature4': 'Acompanhe reservas', + 'register.feature5': 'Listas de malas', + 'register.feature6': 'Fotos e arquivos', + 'register.createAccount': 'Criar conta', + 'register.startPlanning': 'Comece a planejar', + 'register.minChars': 'Mín. 6 caracteres', + 'register.confirmPassword': 'Confirmar senha', + 'register.repeatPassword': 'Repita a senha', + 'register.registering': 'Cadastrando...', + 'register.register': 'Cadastrar', + 'register.hasAccount': 'Já tem conta?', + 'register.signIn': 'Entrar', + + // Admin + 'admin.title': 'Administração', + 'admin.subtitle': 'Gestão de usuários e configurações do sistema', + 'admin.tabs.users': 'Usuários', + 'admin.tabs.categories': 'Categorias', + 'admin.tabs.backup': 'Backup', + 'admin.stats.users': 'Usuários', + 'admin.stats.trips': 'Viagens', + 'admin.stats.places': 'Lugares', + 'admin.stats.photos': 'Fotos', + 'admin.stats.files': 'Arquivos', + 'admin.table.user': 'Usuário', + 'admin.table.email': 'E-mail', + 'admin.table.role': 'Função', + 'admin.table.created': 'Criado', + 'admin.table.lastLogin': 'Último acesso', + 'admin.table.actions': 'Ações', + 'admin.you': '(Você)', + 'admin.editUser': 'Editar usuário', + 'admin.newPassword': 'Nova senha', + 'admin.newPasswordHint': 'Deixe em branco para manter a senha atual', + 'admin.deleteUser': 'Excluir o usuário "{name}"? Todas as viagens serão excluídas permanentemente.', + 'admin.deleteUserTitle': 'Excluir usuário', + 'admin.newPasswordPlaceholder': 'Digite a nova senha…', + 'admin.toast.loadError': 'Falha ao carregar dados do admin', + 'admin.toast.userUpdated': 'Usuário atualizado', + 'admin.toast.updateError': 'Falha ao atualizar', + 'admin.toast.userDeleted': 'Usuário excluído', + 'admin.toast.deleteError': 'Falha ao excluir', + 'admin.toast.cannotDeleteSelf': 'Não é possível excluir a própria conta', + 'admin.toast.userCreated': 'Usuário criado', + 'admin.toast.createError': 'Falha ao criar usuário', + 'admin.toast.fieldsRequired': 'Nome de usuário, e-mail e senha são obrigatórios', + 'admin.createUser': 'Criar usuário', + 'admin.invite.title': 'Links de convite', + 'admin.invite.subtitle': 'Crie links de cadastro de uso único', + 'admin.invite.create': 'Criar link', + 'admin.invite.createAndCopy': 'Criar e copiar', + 'admin.invite.empty': 'Nenhum link de convite criado ainda', + 'admin.invite.maxUses': 'Máx. usos', + 'admin.invite.expiry': 'Expira após', + 'admin.invite.uses': 'usado(s)', + 'admin.invite.expiresAt': 'expira', + 'admin.invite.createdBy': 'por', + 'admin.invite.active': 'Ativo', + 'admin.invite.expired': 'Expirado', + 'admin.invite.usedUp': 'Esgotado', + 'admin.invite.copied': 'Link de convite copiado para a área de transferência', + 'admin.invite.copyLink': 'Copiar link', + 'admin.invite.deleted': 'Link de convite excluído', + 'admin.invite.createError': 'Falha ao criar link de convite', + 'admin.invite.deleteError': 'Falha ao excluir link de convite', + 'admin.tabs.settings': 'Configurações', + 'admin.allowRegistration': 'Permitir cadastro', + 'admin.allowRegistrationHint': 'Novos usuários podem se cadastrar sozinhos', + 'admin.apiKeys': 'Chaves de API', + 'admin.apiKeysHint': 'Opcional. Habilita dados estendidos de lugares, como fotos e clima.', + 'admin.mapsKey': 'Chave da API Google Maps', + 'admin.mapsKeyHint': 'Necessária para busca de lugares. Obtenha em console.cloud.google.com', + 'admin.mapsKeyHintLong': 'Sem chave de API, o OpenStreetMap é usado na busca. Com uma chave Google, também podem ser carregadas fotos, avaliações e horários. Obtenha em console.cloud.google.com.', + 'admin.recommended': 'Recomendado', + 'admin.weatherKey': 'Chave OpenWeatherMap', + 'admin.weatherKeyHint': 'Para dados meteorológicos. Grátis em openweathermap.org', + 'admin.validateKey': 'Testar', + 'admin.keyValid': 'Conectado', + 'admin.keyInvalid': 'Inválida', + 'admin.keySaved': 'Chaves de API salvas', + 'admin.oidcTitle': 'Single Sign-On (OIDC)', + 'admin.oidcSubtitle': 'Permitir login via provedores externos como Google, Apple, Authentik ou Keycloak.', + 'admin.oidcDisplayName': 'Nome exibido', + 'admin.oidcIssuer': 'URL do emissor', + 'admin.oidcIssuerHint': 'URL do emissor OpenID Connect do provedor, ex.: https://accounts.google.com', + 'admin.oidcSaved': 'Configuração OIDC salva', + 'admin.oidcOnlyMode': 'Desativar login por senha', + 'admin.oidcOnlyModeHint': 'Quando ativado, só é permitido login SSO. Login e cadastro por senha ficam bloqueados.', + + // File Types + 'admin.fileTypes': 'Tipos de arquivo permitidos', + 'admin.fileTypesHint': 'Configure quais tipos de arquivo os usuários podem enviar.', + 'admin.fileTypesFormat': 'Extensões separadas por vírgula (ex.: jpg,png,pdf,doc). Use * para permitir todos.', + 'admin.fileTypesSaved': 'Configurações de tipos de arquivo salvas', + + // Packing Templates & Bag Tracking + 'admin.bagTracking.title': 'Rastreamento de malas', + 'admin.bagTracking.subtitle': 'Ativar peso e atribuição de mala para itens da lista', + 'admin.tabs.config': 'Configuração', + 'admin.tabs.templates': 'Modelos de mala', + 'admin.packingTemplates.title': 'Modelos de mala', + 'admin.packingTemplates.subtitle': 'Crie listas de mala reutilizáveis para suas viagens', + 'admin.packingTemplates.create': 'Novo modelo', + 'admin.packingTemplates.namePlaceholder': 'Nome do modelo (ex.: Praia)', + 'admin.packingTemplates.empty': 'Nenhum modelo criado ainda', + 'admin.packingTemplates.items': 'itens', + 'admin.packingTemplates.categories': 'categorias', + 'admin.packingTemplates.itemName': 'Nome do item', + 'admin.packingTemplates.itemCategory': 'Categoria', + 'admin.packingTemplates.categoryName': 'Nome da categoria (ex.: Roupas)', + 'admin.packingTemplates.addCategory': 'Adicionar categoria', + 'admin.packingTemplates.created': 'Modelo criado', + 'admin.packingTemplates.deleted': 'Modelo excluído', + 'admin.packingTemplates.loadError': 'Falha ao carregar modelos', + 'admin.packingTemplates.createError': 'Falha ao criar modelo', + 'admin.packingTemplates.deleteError': 'Falha ao excluir modelo', + 'admin.packingTemplates.saveError': 'Falha ao salvar', + + // Addons + 'admin.tabs.addons': 'Complementos', + 'admin.addons.title': 'Complementos', + 'admin.addons.subtitle': 'Ative ou desative recursos para personalizar sua experiência no TREK.', + 'admin.addons.catalog.memories.name': 'Memórias', + 'admin.addons.catalog.memories.description': 'Álbuns de fotos compartilhados em cada viagem', + 'admin.addons.catalog.packing.name': 'Mala', + 'admin.addons.catalog.packing.description': 'Listas para preparar a bagagem de cada viagem', + 'admin.addons.catalog.budget.name': 'Orçamento', + 'admin.addons.catalog.budget.description': 'Acompanhe despesas e planeje o orçamento da viagem', + 'admin.addons.catalog.documents.name': 'Documentos', + 'admin.addons.catalog.documents.description': 'Armazene e gerencie documentos de viagem', + 'admin.addons.catalog.vacay.name': 'Vacay', + 'admin.addons.catalog.vacay.description': 'Planejador de férias pessoal com visão em calendário', + 'admin.addons.catalog.atlas.name': 'Atlas', + 'admin.addons.catalog.atlas.description': 'Mapa mundial com países visitados e estatísticas', + 'admin.addons.catalog.collab.name': 'Colab', + 'admin.addons.catalog.collab.description': 'Notas, enquetes e chat em tempo real para planejar a viagem', + 'admin.addons.subtitleBefore': 'Ative ou desative recursos para personalizar sua ', + 'admin.addons.subtitleAfter': ' experiência.', + 'admin.addons.enabled': 'Ativado', + 'admin.addons.disabled': 'Desativado', + 'admin.addons.type.trip': 'Viagem', + 'admin.addons.type.global': 'Global', + 'admin.addons.tripHint': 'Disponível como aba em cada viagem', + 'admin.addons.globalHint': 'Disponível como seção própria na navegação principal', + 'admin.addons.toast.updated': 'Complemento atualizado', + 'admin.addons.toast.error': 'Falha ao atualizar complemento', + 'admin.addons.noAddons': 'Nenhum complemento disponível', + // Weather info + 'admin.weather.title': 'Dados meteorológicos', + 'admin.weather.badge': 'Desde 24 de março de 2026', + 'admin.weather.description': 'O TREK usa Open-Meteo como fonte de clima. Open-Meteo é um serviço gratuito e de código aberto — sem chave de API.', + 'admin.weather.forecast': 'Previsão de 16 dias', + 'admin.weather.forecastDesc': 'Antes eram 5 dias (OpenWeatherMap)', + 'admin.weather.climate': 'Dados climáticos históricos', + 'admin.weather.climateDesc': 'Médias dos últimos 85 anos para dias além da previsão de 16 dias', + 'admin.weather.requests': '10.000 requisições / dia', + 'admin.weather.requestsDesc': 'Grátis, sem chave de API', + 'admin.weather.locationHint': 'O clima usa o primeiro lugar com coordenadas de cada dia. Se nenhum lugar estiver atribuído ao dia, qualquer lugar da lista serve como referência.', + + // GitHub + 'admin.tabs.github': 'GitHub', + 'admin.github.title': 'Histórico de versões', + 'admin.github.subtitle': 'Últimas atualizações de {repo}', + 'admin.github.latest': 'Mais recente', + 'admin.github.prerelease': 'Pré-lançamento', + 'admin.github.showDetails': 'Mostrar detalhes', + 'admin.github.hideDetails': 'Ocultar detalhes', + 'admin.github.loadMore': 'Carregar mais', + 'admin.github.loading': 'Carregando...', + 'admin.github.error': 'Falha ao carregar versões', + 'admin.github.by': 'por', + 'admin.github.support': 'Ajuda a continuar desenvolvendo o TREK', + + 'admin.update.available': 'Atualização disponível', + 'admin.update.text': 'O TREK {version} está disponível. Você está na {current}.', + 'admin.update.button': 'Ver no GitHub', + 'admin.update.install': 'Instalar atualização', + 'admin.update.confirmTitle': 'Instalar atualização?', + 'admin.update.confirmText': 'O TREK será atualizado de {current} para {version}. O servidor reiniciará automaticamente em seguida.', + 'admin.update.dataInfo': 'Todos os seus dados (viagens, usuários, chaves de API, envios, Vacay, Atlas, orçamentos) serão preservados.', + 'admin.update.warning': 'O app ficará brevemente indisponível durante o reinício.', + 'admin.update.confirm': 'Atualizar agora', + 'admin.update.installing': 'Atualizando…', + 'admin.update.success': 'Atualização instalada! O servidor está reiniciando…', + 'admin.update.failed': 'Falha na atualização', + 'admin.update.backupHint': 'Recomendamos criar um backup antes de atualizar.', + 'admin.update.backupLink': 'Ir para Backup', + 'admin.update.howTo': 'Como atualizar', + 'admin.update.dockerText': 'Sua instância TREK roda no Docker. Para atualizar para {version}, execute no servidor:', + 'admin.update.reloadHint': 'Recarregue a página em alguns segundos.', + + // Vacay addon + 'vacay.subtitle': 'Planeje e gerencie dias de férias', + 'vacay.settings': 'Configurações', + 'vacay.year': 'Ano', + 'vacay.addYear': 'Adicionar ano', + 'vacay.removeYear': 'Remover ano', + 'vacay.removeYearConfirm': 'Remover {year}?', + 'vacay.removeYearHint': 'Todas as entradas de férias e feriados da empresa deste ano serão excluídas permanentemente.', + 'vacay.remove': 'Remover', + 'vacay.persons': 'Pessoas', + 'vacay.noPersons': 'Nenhuma pessoa adicionada', + 'vacay.addPerson': 'Adicionar pessoa', + 'vacay.editPerson': 'Editar pessoa', + 'vacay.removePerson': 'Remover pessoa', + 'vacay.removePersonConfirm': 'Remover {name}?', + 'vacay.removePersonHint': 'Todas as entradas de férias desta pessoa serão excluídas permanentemente.', + 'vacay.personName': 'Nome', + 'vacay.personNamePlaceholder': 'Digite o nome', + 'vacay.color': 'Cor', + 'vacay.add': 'Adicionar', + 'vacay.legend': 'Legenda', + 'vacay.publicHoliday': 'Feriado nacional', + 'vacay.companyHoliday': 'Feriado da empresa', + 'vacay.weekend': 'Fim de semana', + 'vacay.modeVacation': 'Férias', + 'vacay.modeCompany': 'Feriado da empresa', + 'vacay.entitlement': 'Direito', + 'vacay.entitlementDays': 'Dias', + 'vacay.used': 'Usados', + 'vacay.remaining': 'Restantes', + 'vacay.carriedOver': 'de {year}', + 'vacay.blockWeekends': 'Bloquear fins de semana', + 'vacay.weekendDays': 'Dias de fim de semana', + 'vacay.mon': 'Seg', + 'vacay.tue': 'Ter', + 'vacay.wed': 'Qua', + 'vacay.thu': 'Qui', + 'vacay.fri': 'Sex', + 'vacay.sat': 'Sáb', + 'vacay.sun': 'Dom', + 'vacay.blockWeekendsHint': 'Impedir entradas de férias aos sábados e domingos', + 'vacay.publicHolidays': 'Feriados nacionais', + 'vacay.publicHolidaysHint': 'Marcar feriados nacionais no calendário', + 'vacay.selectCountry': 'Selecione o país', + 'vacay.selectRegion': 'Selecione a região (opcional)', + 'vacay.addCalendar': 'Adicionar calendário', + 'vacay.calendarLabel': 'Rótulo (opcional)', + 'vacay.calendarColor': 'Cor', + 'vacay.noCalendars': 'Nenhum calendário de feriados adicionado ainda', + 'vacay.companyHolidays': 'Feriados da empresa', + 'vacay.companyHolidaysHint': 'Permitir marcar dias de feriado em toda a empresa', + 'vacay.companyHolidaysNoDeduct': 'Feriados da empresa não contam como dias de férias.', + 'vacay.carryOver': 'Acúmulo', + 'vacay.carryOverHint': 'Levar automaticamente os dias de férias restantes para o ano seguinte', + 'vacay.sharing': 'Compartilhamento', + 'vacay.sharingHint': 'Compartilhe seu plano de férias com outros usuários do TREK', + 'vacay.owner': 'Proprietário', + 'vacay.shareEmailPlaceholder': 'E-mail do usuário TREK', + 'vacay.shareSuccess': 'Plano compartilhado com sucesso', + 'vacay.shareError': 'Não foi possível compartilhar o plano', + 'vacay.dissolve': 'Encerrar fusão', + 'vacay.dissolveHint': 'Separar os calendários novamente. Suas entradas serão mantidas.', + 'vacay.dissolveAction': 'Encerrar', + 'vacay.dissolved': 'Calendário separado', + 'vacay.fusedWith': 'Fundido com', + 'vacay.you': 'você', + 'vacay.noData': 'Sem dados', + 'vacay.changeColor': 'Alterar cor', + 'vacay.inviteUser': 'Convidar usuário', + 'vacay.inviteHint': 'Convide outro usuário TREK para compartilhar um calendário de férias combinado.', + 'vacay.selectUser': 'Selecionar usuário', + 'vacay.sendInvite': 'Enviar convite', + 'vacay.inviteSent': 'Convite enviado', + 'vacay.inviteError': 'Não foi possível enviar o convite', + 'vacay.pending': 'pendente', + 'vacay.noUsersAvailable': 'Nenhum usuário disponível', + 'vacay.accept': 'Aceitar', + 'vacay.decline': 'Recusar', + 'vacay.acceptFusion': 'Aceitar e fundir', + 'vacay.inviteTitle': 'Pedido de fusão', + 'vacay.inviteWantsToFuse': 'quer compartilhar um calendário de férias com você.', + 'vacay.fuseInfo1': 'Ambos verão todas as entradas de férias em um calendário compartilhado.', + 'vacay.fuseInfo2': 'Ambos podem criar e editar entradas um do outro.', + 'vacay.fuseInfo3': 'Ambos podem excluir entradas e alterar direitos de férias.', + 'vacay.fuseInfo4': 'Configurações como feriados nacionais e da empresa são compartilhadas.', + 'vacay.fuseInfo5': 'A fusão pode ser encerrada a qualquer momento por qualquer parte. Suas entradas serão preservadas.', + 'nav.myTrips': 'Minhas viagens', + + // Atlas addon + 'atlas.subtitle': 'Sua pegada de viagens pelo mundo', + 'atlas.countries': 'Países', + 'atlas.trips': 'Viagens', + 'atlas.places': 'Lugares', + 'atlas.unmark': 'Remover', + 'atlas.confirmMark': 'Marcar este país como visitado?', + 'atlas.confirmUnmark': 'Remover este país da lista de visitados?', + 'atlas.markVisited': 'Marcar como visitado', + 'atlas.markVisitedHint': 'Adicionar este país à lista de visitados', + 'atlas.addToBucket': 'Adicionar à lista de desejos', + 'atlas.addToBucketHint': 'Salvar como lugar que você quer visitar', + 'atlas.bucketWhen': 'Quando pretende visitar?', + 'atlas.statsTab': 'Estatísticas', + 'atlas.bucketTab': 'Lista de desejos', + 'atlas.addBucket': 'Adicionar à lista de desejos', + 'atlas.bucketNamePlaceholder': 'Lugar ou destino...', + 'atlas.bucketNotesPlaceholder': 'Notas (opcional)', + 'atlas.bucketEmpty': 'Sua lista de desejos está vazia', + 'atlas.bucketEmptyHint': 'Adicione lugares que sonha em visitar', + 'atlas.days': 'Dias', + 'atlas.visitedCountries': 'Países visitados', + 'atlas.cities': 'Cidades', + 'atlas.noData': 'Ainda sem dados de viagem', + 'atlas.noDataHint': 'Crie uma viagem e adicione lugares para ver o mapa mundial', + 'atlas.lastTrip': 'Última viagem', + 'atlas.nextTrip': 'Próxima viagem', + 'atlas.daysLeft': 'dias restantes', + 'atlas.streak': 'Sequência', + 'atlas.year': 'ano', + 'atlas.years': 'anos', + 'atlas.yearInRow': 'ano seguido', + 'atlas.yearsInRow': 'anos seguidos', + 'atlas.tripIn': 'viagem em', + 'atlas.tripsIn': 'viagens em', + 'atlas.since': 'desde', + 'atlas.europe': 'Europa', + 'atlas.asia': 'Ásia', + 'atlas.northAmerica': 'América do Norte', + 'atlas.southAmerica': 'América do Sul', + 'atlas.africa': 'África', + 'atlas.oceania': 'Oceania', + 'atlas.other': 'Outro', + 'atlas.firstVisit': 'Primeira viagem', + 'atlas.lastVisitLabel': 'Última viagem', + 'atlas.tripSingular': 'Viagem', + 'atlas.tripPlural': 'Viagens', + 'atlas.placeVisited': 'Lugar visitado', + 'atlas.placesVisited': 'Lugares visitados', + + // Trip Planner + 'trip.tabs.plan': 'Plano', + 'trip.tabs.reservations': 'Reservas', + 'trip.tabs.reservationsShort': 'Reservas', + 'trip.tabs.packing': 'Lista de mala', + 'trip.tabs.packingShort': 'Mala', + 'trip.tabs.budget': 'Orçamento', + 'trip.tabs.files': 'Arquivos', + 'trip.loading': 'Carregando viagem...', + 'trip.mobilePlan': 'Plano', + 'trip.mobilePlaces': 'Lugares', + 'trip.toast.placeUpdated': 'Lugar atualizado', + 'trip.toast.placeAdded': 'Lugar adicionado', + 'trip.toast.placeDeleted': 'Lugar excluído', + 'trip.toast.selectDay': 'Selecione um dia primeiro', + 'trip.toast.assignedToDay': 'Lugar atribuído ao dia', + 'trip.toast.reorderError': 'Falha ao reordenar', + 'trip.toast.reservationUpdated': 'Reserva atualizada', + 'trip.toast.reservationAdded': 'Reserva adicionada', + 'trip.toast.deleted': 'Excluído', + 'trip.confirm.deletePlace': 'Tem certeza de que deseja excluir este lugar?', + + // Day Plan Sidebar + 'dayplan.emptyDay': 'Nenhum lugar planejado para este dia', + 'dayplan.addNote': 'Adicionar nota', + 'dayplan.editNote': 'Editar nota', + 'dayplan.noteAdd': 'Adicionar nota', + 'dayplan.noteEdit': 'Editar nota', + 'dayplan.noteTitle': 'Nota', + 'dayplan.noteSubtitle': 'Nota do dia', + 'dayplan.totalCost': 'Custo total', + 'dayplan.days': 'Dias', + 'dayplan.dayN': 'Dia {n}', + 'dayplan.calculating': 'Calculando...', + 'dayplan.route': 'Rota', + 'dayplan.optimize': 'Otimizar', + 'dayplan.optimized': 'Rota otimizada', + 'dayplan.routeError': 'Falha ao calcular a rota', + 'dayplan.toast.needTwoPlaces': 'São necessários pelo menos dois lugares para otimizar a rota', + 'dayplan.toast.routeOptimized': 'Rota otimizada', + 'dayplan.toast.noGeoPlaces': 'Nenhum lugar com coordenadas para calcular a rota', + 'dayplan.confirmed': 'Confirmada', + 'dayplan.pendingRes': 'Pendente', + 'dayplan.pdf': 'PDF', + 'dayplan.pdfTooltip': 'Exportar plano do dia em PDF', + 'dayplan.pdfError': 'Falha ao exportar PDF', + + // Places Sidebar + 'places.addPlace': 'Adicionar lugar/atividade', + 'places.assignToDay': 'Adicionar a qual dia?', + 'places.all': 'Todos', + 'places.unplanned': 'Não planejados', + 'places.search': 'Buscar lugares...', + 'places.allCategories': 'Todas as categorias', + 'places.count': '{count} lugares', + 'places.countSingular': '1 lugar', + 'places.allPlanned': 'Todos os lugares estão planejados', + 'places.noneFound': 'Nenhum lugar encontrado', + 'places.editPlace': 'Editar lugar', + 'places.formName': 'Nome', + 'places.formNamePlaceholder': 'ex.: Torre Eiffel', + 'places.formDescription': 'Descrição', + 'places.formDescriptionPlaceholder': 'Breve descrição...', + 'places.formAddress': 'Endereço', + 'places.formAddressPlaceholder': 'Rua, cidade, país', + 'places.formLat': 'Latitude (ex.: -23.5505)', + 'places.formLng': 'Longitude (ex.: -46.6333)', + 'places.formCategory': 'Categoria', + 'places.noCategory': 'Sem categoria', + 'places.categoryNamePlaceholder': 'Nome da categoria', + 'places.formTime': 'Horário', + 'places.startTime': 'Início', + 'places.endTime': 'Fim', + 'places.endTimeBeforeStart': 'O horário de fim é antes do início', + 'places.timeCollision': 'Sobreposição de horário com:', + 'places.formWebsite': 'Site', + 'places.formNotesPlaceholder': 'Notas pessoais...', + 'places.formReservation': 'Reserva', + 'places.reservationNotesPlaceholder': 'Notas da reserva, código de confirmação...', + 'places.mapsSearchPlaceholder': 'Buscar lugares...', + 'places.mapsSearchError': 'Falha na busca de lugares.', + 'places.osmHint': 'Busca via OpenStreetMap (sem fotos, horários ou avaliações). Adicione uma chave Google nas configurações para detalhes completos.', + 'places.osmActive': 'Busca via OpenStreetMap (sem fotos, avaliações ou horário de funcionamento). Adicione uma chave Google em Configurações para mais dados.', + 'places.categoryCreateError': 'Falha ao criar categoria', + 'places.nameRequired': 'Digite um nome', + 'places.saveError': 'Falha ao salvar', + // Place Inspector + 'inspector.opened': 'Aberto', + 'inspector.closed': 'Fechado', + 'inspector.openingHours': 'Horário de funcionamento', + 'inspector.showHours': 'Mostrar horário de funcionamento', + 'inspector.files': 'Arquivos', + 'inspector.filesCount': '{count} arquivos', + 'inspector.removeFromDay': 'Remover do dia', + 'inspector.addToDay': 'Adicionar ao dia', + 'inspector.confirmedRes': 'Reserva confirmada', + 'inspector.pendingRes': 'Reserva pendente', + 'inspector.google': 'Abrir no Google Maps', + 'inspector.website': 'Abrir site', + 'inspector.addRes': 'Reserva', + 'inspector.editRes': 'Editar reserva', + 'inspector.participants': 'Participantes', + + // Reservations + 'reservations.title': 'Reservas', + 'reservations.empty': 'Nenhuma reserva ainda', + 'reservations.emptyHint': 'Adicione reservas de voos, hotéis e mais', + 'reservations.add': 'Adicionar reserva', + 'reservations.addManual': 'Reserva manual', + 'reservations.placeHint': 'Dica: o ideal é criar reservas a partir de um lugar para vinculá-las ao plano do dia.', + 'reservations.confirmed': 'Confirmada', + 'reservations.pending': 'Pendente', + 'reservations.summary': '{confirmed} confirmada(s), {pending} pendente(s)', + 'reservations.fromPlan': 'Do plano', + 'reservations.showFiles': 'Mostrar arquivos', + 'reservations.editTitle': 'Editar reserva', + 'reservations.status': 'Status', + 'reservations.datetime': 'Data e hora', + 'reservations.startTime': 'Horário de início', + 'reservations.endTime': 'Horário de término', + 'reservations.date': 'Data', + 'reservations.time': 'Hora', + 'reservations.timeAlt': 'Hora (alternativa, ex.: 19:30)', + 'reservations.notes': 'Notas', + 'reservations.notesPlaceholder': 'Notas adicionais...', + 'reservations.meta.airline': 'Companhia aérea', + 'reservations.meta.flightNumber': 'Nº do voo', + 'reservations.meta.from': 'De', + 'reservations.meta.to': 'Para', + 'reservations.meta.trainNumber': 'Nº do trem', + 'reservations.meta.platform': 'Plataforma', + 'reservations.meta.seat': 'Assento', + 'reservations.meta.checkIn': 'Check-in', + 'reservations.meta.checkOut': 'Check-out', + 'reservations.meta.linkAccommodation': 'Hospedagem', + 'reservations.meta.pickAccommodation': 'Vincular à hospedagem', + 'reservations.meta.noAccommodation': 'Nenhuma', + 'reservations.meta.hotelPlace': 'Hospedagem', + 'reservations.meta.pickHotel': 'Selecionar hospedagem', + 'reservations.meta.fromDay': 'De', + 'reservations.meta.toDay': 'Até', + 'reservations.meta.selectDay': 'Selecionar dia', + 'reservations.type.flight': 'Voo', + 'reservations.type.hotel': 'Hospedagem', + 'reservations.type.restaurant': 'Restaurante', + 'reservations.type.train': 'Trem', + 'reservations.type.car': 'Carro alugado', + 'reservations.type.cruise': 'Cruzeiro', + 'reservations.type.event': 'Evento', + 'reservations.type.tour': 'Passeio', + 'reservations.type.other': 'Outro', + 'reservations.confirm.delete': 'Tem certeza de que deseja excluir a reserva "{name}"?', + 'reservations.toast.updated': 'Reserva atualizada', + 'reservations.toast.removed': 'Reserva excluída', + 'reservations.toast.fileUploaded': 'Arquivo enviado', + 'reservations.toast.uploadError': 'Falha no envio', + 'reservations.newTitle': 'Nova reserva', + 'reservations.bookingType': 'Tipo de reserva', + 'reservations.titleLabel': 'Título', + 'reservations.titlePlaceholder': 'ex.: LATAM LA800, Hotel Copacabana...', + 'reservations.locationAddress': 'Local / endereço', + 'reservations.locationPlaceholder': 'Endereço, aeroporto, hotel...', + 'reservations.confirmationCode': 'Código da reserva', + 'reservations.confirmationPlaceholder': 'ex.: ABC12345', + 'reservations.day': 'Dia', + 'reservations.noDay': 'Sem dia', + 'reservations.place': 'Lugar', + 'reservations.noPlace': 'Sem lugar', + 'reservations.pendingSave': 'será salvo…', + 'reservations.uploading': 'Enviando...', + 'reservations.attachFile': 'Anexar arquivo', + 'reservations.linkExisting': 'Vincular arquivo existente', + 'reservations.toast.saveError': 'Falha ao salvar', + 'reservations.toast.updateError': 'Falha ao atualizar', + 'reservations.toast.deleteError': 'Falha ao excluir', + 'reservations.confirm.remove': 'Remover a reserva "{name}"?', + 'reservations.linkAssignment': 'Vincular à atribuição do dia', + 'reservations.pickAssignment': 'Selecione uma atribuição do seu plano...', + 'reservations.noAssignment': 'Sem vínculo (avulsa)', + + // Budget + 'budget.title': 'Orçamento', + 'budget.emptyTitle': 'Nenhum orçamento criado ainda', + 'budget.emptyText': 'Crie categorias e lançamentos para planejar o orçamento da viagem', + 'budget.emptyPlaceholder': 'Nome da categoria...', + 'budget.createCategory': 'Criar categoria', + 'budget.category': 'Categoria', + 'budget.categoryName': 'Nome da categoria', + 'budget.table.name': 'Nome', + 'budget.table.total': 'Total', + 'budget.table.persons': 'Pessoas', + 'budget.table.days': 'Dias', + 'budget.table.perPerson': 'Por pessoa', + 'budget.table.perDay': 'Por dia', + 'budget.table.perPersonDay': 'P. p. / dia', + 'budget.table.note': 'Obs.', + 'budget.newEntry': 'Novo lançamento', + 'budget.defaultEntry': 'Novo lançamento', + 'budget.defaultCategory': 'Nova categoria', + 'budget.total': 'Total', + 'budget.totalBudget': 'Orçamento total', + 'budget.byCategory': 'Por categoria', + 'budget.editTooltip': 'Clique para editar', + 'budget.confirm.deleteCategory': 'Excluir a categoria "{name}" com {count} lançamento(s)?', + 'budget.deleteCategory': 'Excluir categoria', + 'budget.perPerson': 'Por pessoa', + 'budget.paid': 'Pago', + 'budget.open': 'Em aberto', + 'budget.noMembers': 'Nenhum membro atribuído', + + // Files + 'files.title': 'Arquivos', + 'files.count': '{count} arquivos', + 'files.countSingular': '1 arquivo', + 'files.uploaded': '{count} enviado(s)', + 'files.uploadError': 'Falha no envio', + 'files.dropzone': 'Solte os arquivos aqui', + 'files.dropzoneHint': 'ou clique para escolher', + 'files.allowedTypes': 'Imagens, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Máx. 50 MB', + 'files.uploading': 'Enviando...', + 'files.filterAll': 'Todos', + 'files.filterPdf': 'PDFs', + 'files.filterImages': 'Imagens', + 'files.filterDocs': 'Documentos', + 'files.filterCollab': 'Notas Colab', + 'files.sourceCollab': 'Das notas Colab', + 'files.empty': 'Nenhum arquivo ainda', + 'files.emptyHint': 'Envie arquivos para anexá-los à viagem', + 'files.openTab': 'Abrir em nova aba', + 'files.confirm.delete': 'Excluir este arquivo?', + 'files.toast.deleted': 'Arquivo excluído', + 'files.toast.deleteError': 'Falha ao excluir arquivo', + 'files.sourcePlan': 'Plano do dia', + 'files.sourceBooking': 'Reserva', + 'files.attach': 'Anexar', + 'files.pasteHint': 'Você também pode colar imagens da área de transferência (Ctrl+V)', + 'files.trash': 'Lixeira', + 'files.trashEmpty': 'A lixeira está vazia', + 'files.emptyTrash': 'Esvaziar lixeira', + 'files.restore': 'Restaurar', + 'files.star': 'Favoritar', + 'files.unstar': 'Remover favorito', + 'files.assign': 'Atribuir', + 'files.assignTitle': 'Atribuir arquivo', + 'files.assignPlace': 'Lugar', + 'files.assignBooking': 'Reserva', + 'files.unassigned': 'Não atribuído', + 'files.unlink': 'Remover vínculo', + 'files.toast.trashed': 'Movido para a lixeira', + 'files.toast.restored': 'Arquivo restaurado', + 'files.toast.trashEmptied': 'Lixeira esvaziada', + 'files.toast.assigned': 'Arquivo atribuído', + 'files.toast.assignError': 'Falha na atribuição', + 'files.toast.restoreError': 'Falha ao restaurar', + 'files.confirm.permanentDelete': 'Excluir permanentemente este arquivo? Não é possível desfazer.', + 'files.confirm.emptyTrash': 'Excluir permanentemente todos os arquivos na lixeira? Não é possível desfazer.', + 'files.noteLabel': 'Nota', + 'files.notePlaceholder': 'Adicione uma nota...', + + // Packing + 'packing.title': 'Lista de mala', + 'packing.empty': 'A lista de mala está vazia', + 'packing.progress': '{packed} de {total} na mala ({percent}%)', + 'packing.clearChecked': 'Remover {count} marcado(s)', + 'packing.clearCheckedShort': 'Remover {count}', + 'packing.suggestions': 'Sugestões', + 'packing.suggestionsTitle': 'Adicionar sugestões', + 'packing.allSuggested': 'Todas as sugestões adicionadas', + 'packing.allPacked': 'Tudo na mala!', + 'packing.addPlaceholder': 'Adicionar item...', + 'packing.categoryPlaceholder': 'Categoria...', + 'packing.filterAll': 'Todos', + 'packing.filterOpen': 'Abertos', + 'packing.filterDone': 'Prontos', + 'packing.emptyTitle': 'A lista de mala está vazia', + 'packing.emptyHint': 'Adicione itens ou use as sugestões', + 'packing.emptyFiltered': 'Nenhum item corresponde ao filtro', + 'packing.menuRename': 'Renomear', + 'packing.menuCheckAll': 'Marcar todos', + 'packing.menuUncheckAll': 'Desmarcar todos', + 'packing.menuDeleteCat': 'Excluir categoria', + 'packing.assignUser': 'Atribuir usuário', + 'packing.noMembers': 'Nenhum membro na viagem', + 'packing.addItem': 'Adicionar item', + 'packing.addItemPlaceholder': 'Nome do item...', + 'packing.addCategory': 'Adicionar categoria', + 'packing.newCategoryPlaceholder': 'Nome da categoria (ex.: Roupas)', + 'packing.applyTemplate': 'Aplicar modelo', + 'packing.template': 'Modelo', + 'packing.templateApplied': '{count} itens adicionados do modelo', + 'packing.templateError': 'Falha ao aplicar modelo', + 'packing.bags': 'Malas', + 'packing.noBag': 'Sem mala', + 'packing.totalWeight': 'Peso total', + 'packing.bagName': 'Nome da mala...', + 'packing.addBag': 'Adicionar mala', + 'packing.changeCategory': 'Alterar categoria', + 'packing.confirm.clearChecked': 'Remover {count} item(ns) marcado(s)?', + 'packing.confirm.deleteCat': 'Excluir a categoria "{name}" com {count} item(ns)?', + 'packing.defaultCategory': 'Outros', + 'packing.toast.saveError': 'Falha ao salvar', + 'packing.toast.deleteError': 'Falha ao excluir', + 'packing.toast.renameError': 'Falha ao renomear', + 'packing.toast.addError': 'Falha ao adicionar', + + // Packing suggestions + 'packing.suggestions.items': [ + { name: 'Passaporte', category: 'Documentos' }, + { name: 'Documento de identidade', category: 'Documentos' }, + { name: 'Seguro viagem', category: 'Documentos' }, + { name: 'Passagens aéreas', category: 'Documentos' }, + { name: 'Cartão de crédito', category: 'Finanças' }, + { name: 'Dinheiro', category: 'Finanças' }, + { name: 'Visto', category: 'Documentos' }, + { name: 'Camisetas', category: 'Roupas' }, + { name: 'Calças', category: 'Roupas' }, + { name: 'Roupa íntima', category: 'Roupas' }, + { name: 'Meias', category: 'Roupas' }, + { name: 'Jaqueta', category: 'Roupas' }, + { name: 'Pijama', category: 'Roupas' }, + { name: 'Traje de banho', category: 'Roupas' }, + { name: 'Capa de chuva', category: 'Roupas' }, + { name: 'Sapatos confortáveis', category: 'Roupas' }, + { name: 'Escova de dentes', category: 'Higiene' }, + { name: 'Creme dental', category: 'Higiene' }, + { name: 'Shampoo', category: 'Higiene' }, + { name: 'Desodorante', category: 'Higiene' }, + { name: 'Protetor solar', category: 'Higiene' }, + { name: 'Aparelho de barbear', category: 'Higiene' }, + { name: 'Carregador', category: 'Eletrônicos' }, + { name: 'Power bank', category: 'Eletrônicos' }, + { name: 'Fones de ouvido', category: 'Eletrônicos' }, + { name: 'Adaptador de viagem', category: 'Eletrônicos' }, + { name: 'Câmera', category: 'Eletrônicos' }, + { name: 'Medicamento para dor', category: 'Saúde' }, + { name: 'Curativos', category: 'Saúde' }, + { name: 'Desinfetante', category: 'Saúde' }, + ], + + // Members / Sharing + 'members.shareTrip': 'Compartilhar viagem', + 'members.inviteUser': 'Convidar usuário', + 'members.selectUser': 'Selecionar usuário…', + 'members.invite': 'Convidar', + 'members.allHaveAccess': 'Todos os usuários já têm acesso.', + 'members.access': 'Acesso', + 'members.person': 'pessoa', + 'members.persons': 'pessoas', + 'members.you': 'você', + 'members.owner': 'Proprietário', + 'members.leaveTrip': 'Sair da viagem', + 'members.removeAccess': 'Remover acesso', + 'members.confirmLeave': 'Sair da viagem? Você perderá o acesso.', + 'members.confirmRemove': 'Remover o acesso deste usuário?', + 'members.loadError': 'Falha ao carregar membros', + 'members.added': 'adicionado', + 'members.addError': 'Falha ao adicionar', + 'members.removed': 'Membro removido', + 'members.removeError': 'Falha ao remover', + + // Categories (Admin) + 'categories.title': 'Categorias', + 'categories.subtitle': 'Gerenciar categorias de lugares', + 'categories.new': 'Nova categoria', + 'categories.empty': 'Nenhuma categoria ainda', + 'categories.namePlaceholder': 'Nome da categoria', + 'categories.icon': 'Ícone', + 'categories.color': 'Cor', + 'categories.customColor': 'Escolher cor personalizada', + 'categories.preview': 'Pré-visualização', + 'categories.defaultName': 'Categoria', + 'categories.update': 'Atualizar', + 'categories.create': 'Criar', + 'categories.confirm.delete': 'Excluir categoria? Os lugares desta categoria não serão excluídos.', + 'categories.toast.loadError': 'Falha ao carregar categorias', + 'categories.toast.nameRequired': 'Digite um nome', + 'categories.toast.updated': 'Categoria atualizada', + 'categories.toast.created': 'Categoria criada', + 'categories.toast.saveError': 'Falha ao salvar', + 'categories.toast.deleted': 'Categoria excluída', + 'categories.toast.deleteError': 'Falha ao excluir', + + // Backup (Admin) + 'backup.title': 'Backup de dados', + 'backup.subtitle': 'Banco de dados e todos os arquivos enviados', + 'backup.refresh': 'Atualizar', + 'backup.upload': 'Enviar backup', + 'backup.uploading': 'Enviando…', + 'backup.create': 'Criar backup', + 'backup.creating': 'Criando…', + 'backup.empty': 'Nenhum backup ainda', + 'backup.createFirst': 'Criar primeiro backup', + 'backup.download': 'Baixar', + 'backup.restore': 'Restaurar', + 'backup.confirm.restore': 'Restaurar o backup "{name}"?\n\nTodos os dados atuais serão substituídos pelo backup.', + 'backup.confirm.uploadRestore': 'Enviar e restaurar o arquivo "{name}"?\n\nTodos os dados atuais serão sobrescritos.', + 'backup.confirm.delete': 'Excluir o backup "{name}"?', + 'backup.toast.loadError': 'Falha ao carregar backups', + 'backup.toast.created': 'Backup criado com sucesso', + 'backup.toast.createError': 'Falha ao criar backup', + 'backup.toast.restored': 'Backup restaurado. A página será recarregada…', + 'backup.toast.restoreError': 'Falha ao restaurar', + 'backup.toast.uploadError': 'Falha no envio', + 'backup.toast.deleted': 'Backup excluído', + 'backup.toast.deleteError': 'Falha ao excluir', + 'backup.toast.downloadError': 'Falha no download', + 'backup.toast.settingsSaved': 'Configurações de backup automático salvas', + 'backup.toast.settingsError': 'Falha ao salvar configurações', + 'backup.auto.title': 'Backup automático', + 'backup.auto.subtitle': 'Backup automático em agenda', + 'backup.auto.enable': 'Ativar backup automático', + 'backup.auto.enableHint': 'Backups serão criados automaticamente conforme a agenda escolhida', + 'backup.auto.interval': 'Intervalo', + 'backup.auto.keepLabel': 'Excluir backups antigos após', + 'backup.interval.hourly': 'A cada hora', + 'backup.interval.daily': 'Diário', + 'backup.interval.weekly': 'Semanal', + 'backup.interval.monthly': 'Mensal', + 'backup.keep.1day': '1 dia', + 'backup.keep.3days': '3 dias', + 'backup.keep.7days': '7 dias', + 'backup.keep.14days': '14 dias', + 'backup.keep.30days': '30 dias', + 'backup.keep.forever': 'Manter para sempre', + + // Photos + 'photos.allDays': 'Todos os dias', + 'photos.noPhotos': 'Nenhuma foto ainda', + 'photos.uploadHint': 'Envie suas fotos de viagem', + 'photos.clickToSelect': 'ou clique para selecionar', + 'photos.linkPlace': 'Vincular lugar', + 'photos.noPlace': 'Sem lugar', + 'photos.uploadN': 'Enviar {n} foto(s)', + + // Backup restore modal + 'backup.restoreConfirmTitle': 'Restaurar backup?', + 'backup.restoreWarning': 'Todos os dados atuais (viagens, lugares, usuários, envios) serão permanentemente substituídos pelo backup. Esta ação não pode ser desfeita.', + 'backup.restoreTip': 'Dica: crie um backup do estado atual antes de restaurar.', + 'backup.restoreConfirm': 'Sim, restaurar', + + // PDF + 'pdf.travelPlan': 'Plano de viagem', + 'pdf.planned': 'Planejado', + 'pdf.costLabel': 'Custo (EUR)', + 'pdf.preview': 'Pré-visualização do PDF', + 'pdf.saveAsPdf': 'Salvar como PDF', + + // Planner + 'planner.places': 'Lugares', + 'planner.bookings': 'Reservas', + 'planner.packingList': 'Lista de mala', + 'planner.documents': 'Documentos', + 'planner.dayPlan': 'Plano do dia', + 'planner.reservations': 'Reservas', + 'planner.minTwoPlaces': 'São necessários pelo menos 2 lugares com coordenadas', + 'planner.noGeoPlaces': 'Nenhum lugar com coordenadas disponível', + 'planner.routeCalculated': 'Rota calculada', + 'planner.routeCalcFailed': 'Não foi possível calcular a rota', + 'planner.routeError': 'Erro ao calcular a rota', + 'planner.routeOptimized': 'Rota otimizada', + 'planner.reservationUpdated': 'Reserva atualizada', + 'planner.reservationAdded': 'Reserva adicionada', + 'planner.confirmDeleteReservation': 'Excluir reserva?', + 'planner.reservationDeleted': 'Reserva excluída', + 'planner.days': 'Dias', + 'planner.allPlaces': 'Todos os lugares', + 'planner.totalPlaces': '{n} lugares no total', + 'planner.noDaysPlanned': 'Nenhum dia planejado ainda', + 'planner.editTrip': 'Editar viagem \u2192', + 'planner.placeOne': '1 lugar', + 'planner.placeN': '{n} lugares', + 'planner.addNote': 'Adicionar nota', + 'planner.noEntries': 'Nenhuma entrada neste dia', + 'planner.addPlace': 'Adicionar lugar/atividade', + 'planner.addPlaceShort': '+ Adicionar lugar/atividade', + 'planner.resPending': 'Reserva pendente · ', + 'planner.resConfirmed': 'Reserva confirmada · ', + 'planner.notePlaceholder': 'Nota\u2026', + 'planner.noteTimePlaceholder': 'Horário (opcional)', + 'planner.noteExamplePlaceholder': 'ex.: metrô às 14:30 da estação central, barco do cais 7, pausa para almoço\u2026', + 'planner.totalCost': 'Custo total', + 'planner.searchPlaces': 'Buscar lugares\u2026', + 'planner.allCategories': 'Todas as categorias', + 'planner.noPlacesFound': 'Nenhum lugar encontrado', + 'planner.addFirstPlace': 'Adicionar primeiro lugar', + 'planner.noReservations': 'Nenhuma reserva', + 'planner.addFirstReservation': 'Adicionar primeira reserva', + 'planner.new': 'Novo', + 'planner.addToDay': '+ Dia', + 'planner.calculating': 'Calculando\u2026', + 'planner.route': 'Rota', + 'planner.optimize': 'Otimizar', + 'planner.openGoogleMaps': 'Abrir no Google Maps', + 'planner.selectDayHint': 'Selecione um dia na lista à esquerda para ver o plano do dia', + 'planner.noPlacesForDay': 'Nenhum lugar neste dia ainda', + 'planner.addPlacesLink': 'Adicionar lugares \u2192', + 'planner.minTotal': 'mín. total', + 'planner.noReservation': 'Sem reserva', + 'planner.removeFromDay': 'Remover do dia', + 'planner.addToThisDay': 'Adicionar ao dia', + 'planner.overview': 'Visão geral', + 'planner.noDays': 'Nenhum dia ainda', + 'planner.editTripToAddDays': 'Edite a viagem para adicionar dias', + 'planner.dayCount': '{n} dias', + 'planner.clickToUnlock': 'Clique para desbloquear', + 'planner.keepPosition': 'Manter posição durante a otimização da rota', + 'planner.dayDetails': 'Detalhes do dia', + 'planner.dayN': 'Dia {n}', + + // Dashboard Stats + 'stats.countries': 'Países', + 'stats.cities': 'Cidades', + 'stats.trips': 'Viagens', + 'stats.places': 'Lugares', + 'stats.worldProgress': 'Progresso no mundo', + 'stats.visited': 'visitados', + 'stats.remaining': 'restantes', + 'stats.visitedCountries': 'Países visitados', + + // Day Detail Panel + 'day.precipProb': 'Probabilidade de chuva', + 'day.precipitation': 'Precipitação', + 'day.wind': 'Vento', + 'day.sunrise': 'Nascer do sol', + 'day.sunset': 'Pôr do sol', + 'day.hourlyForecast': 'Previsão por hora', + 'day.climateHint': 'Médias históricas — previsão real disponível até 16 dias desta data.', + 'day.noWeather': 'Sem dados meteorológicos. Adicione um lugar com coordenadas.', + 'day.overview': 'Resumo do dia', + 'day.accommodation': 'Hospedagem', + 'day.addAccommodation': 'Adicionar hospedagem', + 'day.hotelDayRange': 'Aplicar aos dias', + 'day.noPlacesForHotel': 'Adicione lugares à viagem primeiro', + 'day.allDays': 'Todos', + 'day.checkIn': 'Check-in', + 'day.checkOut': 'Check-out', + 'day.confirmation': 'Confirmação', + 'day.editAccommodation': 'Editar hospedagem', + 'day.reservations': 'Reservas', + + // Collab Addon + 'collab.tabs.chat': 'Chat', + 'collab.tabs.notes': 'Notas', + 'collab.tabs.polls': 'Enquetes', + 'collab.whatsNext.title': 'Próximos passos', + 'collab.whatsNext.today': 'Hoje', + 'collab.whatsNext.tomorrow': 'Amanhã', + 'collab.whatsNext.empty': 'Nenhuma atividade próxima', + 'collab.whatsNext.until': 'até', + 'collab.whatsNext.emptyHint': 'Atividades com horário aparecerão aqui', + 'collab.chat.send': 'Enviar', + 'collab.chat.placeholder': 'Digite uma mensagem...', + 'collab.chat.empty': 'Inicie a conversa', + 'collab.chat.emptyHint': 'As mensagens são compartilhadas com todos os membros da viagem', + 'collab.chat.emptyDesc': 'Compartilhe ideias, planos e atualizações com o grupo', + 'collab.chat.today': 'Hoje', + 'collab.chat.yesterday': 'Ontem', + 'collab.chat.deletedMessage': 'apagou uma mensagem', + 'collab.chat.loadMore': 'Carregar mensagens antigas', + 'collab.chat.justNow': 'agora mesmo', + 'collab.chat.minutesAgo': 'há {n} min', + 'collab.chat.hoursAgo': 'há {n} h', + 'collab.notes.title': 'Notas', + 'collab.notes.new': 'Nova nota', + 'collab.notes.empty': 'Nenhuma nota ainda', + 'collab.notes.emptyHint': 'Comece a registrar ideias e planos', + 'collab.notes.all': 'Todas', + 'collab.notes.titlePlaceholder': 'Título da nota', + 'collab.notes.contentPlaceholder': 'Escreva algo...', + 'collab.notes.categoryPlaceholder': 'Categoria', + 'collab.notes.newCategory': 'Nova categoria...', + 'collab.notes.category': 'Categoria', + 'collab.notes.noCategory': 'Sem categoria', + 'collab.notes.color': 'Cor', + 'collab.notes.save': 'Salvar', + 'collab.notes.cancel': 'Cancelar', + 'collab.notes.edit': 'Editar', + 'collab.notes.delete': 'Excluir', + 'collab.notes.pin': 'Fixar', + 'collab.notes.unpin': 'Desafixar', + 'collab.notes.daysAgo': 'há {n} d', + 'collab.notes.categorySettings': 'Gerenciar categorias', + 'collab.notes.create': 'Criar', + 'collab.notes.website': 'Site', + 'collab.notes.websitePlaceholder': 'https://...', + 'collab.notes.attachFiles': 'Anexar arquivos', + 'collab.notes.noCategoriesYet': 'Nenhuma categoria ainda', + 'collab.notes.emptyDesc': 'Crie uma nota para começar', + 'collab.polls.title': 'Enquetes', + 'collab.polls.new': 'Nova enquete', + 'collab.polls.empty': 'Nenhuma enquete ainda', + 'collab.polls.emptyHint': 'Pergunte ao grupo e votem juntos', + 'collab.polls.question': 'Pergunta', + 'collab.polls.questionPlaceholder': 'O que vamos fazer?', + 'collab.polls.addOption': '+ Adicionar opção', + 'collab.polls.optionPlaceholder': 'Opção {n}', + 'collab.polls.create': 'Criar enquete', + 'collab.polls.close': 'Encerrar', + 'collab.polls.closed': 'Encerrada', + 'collab.polls.votes': '{n} votos', + 'collab.polls.vote': '{n} voto', + 'collab.polls.multipleChoice': 'Múltipla escolha', + 'collab.polls.multiChoice': 'Múltipla escolha', + 'collab.polls.deadline': 'Prazo', + 'collab.polls.option': 'Opção', + 'collab.polls.options': 'Opções', + 'collab.polls.delete': 'Excluir', + 'collab.polls.closedSection': 'Encerradas', + + // Memories (Immich Photos) + 'memories.title': 'Fotos', + 'memories.notConnected': 'Immich não conectado', + 'memories.notConnectedHint': 'Conecte sua instância Immich nas Configurações para ver suas fotos de viagem aqui.', + 'memories.noDates': 'Adicione datas à sua viagem para carregar fotos.', + 'memories.noPhotos': 'Nenhuma foto encontrada', + 'memories.noPhotosHint': 'Nenhuma foto encontrada no Immich para o período desta viagem.', + 'memories.photosFound': 'fotos', + 'memories.fromOthers': 'de outros', + 'memories.sharePhotos': 'Compartilhar fotos', + 'memories.sharing': 'Compartilhando', + 'memories.reviewTitle': 'Revise suas fotos', + 'memories.reviewHint': 'Clique nas fotos para excluí-las do compartilhamento.', + 'memories.shareCount': 'Compartilhar {count} fotos', + 'memories.immichUrl': 'URL do servidor Immich', + 'memories.immichApiKey': 'Chave da API', + 'memories.testConnection': 'Testar conexão', + 'memories.connected': 'Conectado', + 'memories.disconnected': 'Não conectado', + 'memories.connectionSuccess': 'Conectado ao Immich', + 'memories.connectionError': 'Não foi possível conectar ao Immich', + 'memories.saved': 'Configurações do Immich salvas', + 'memories.addPhotos': 'Adicionar fotos', + 'memories.selectPhotos': 'Selecionar fotos do Immich', + 'memories.selectHint': 'Toque nas fotos para selecioná-las.', + 'memories.selected': 'selecionadas', + 'memories.addSelected': 'Adicionar {count} fotos', + 'memories.alreadyAdded': 'Já adicionada', + 'memories.private': 'Privado', + 'memories.stopSharing': 'Parar de compartilhar', + 'memories.oldest': 'Mais antigas', + 'memories.newest': 'Mais recentes', + 'memories.allLocations': 'Todos os locais', + 'memories.tripDates': 'Datas da viagem', + 'memories.allPhotos': 'Todas as fotos', + 'memories.confirmShareTitle': 'Compartilhar com membros da viagem?', + 'memories.confirmShareHint': '{count} fotos serão visíveis para todos os membros desta viagem. Você pode tornar fotos individuais privadas depois.', + 'memories.confirmShareButton': 'Compartilhar fotos', +} + +export default br From d04629605e6ed7e15846c911d02344fb66b9b83d Mon Sep 17 00:00:00 2001 From: fgbona Date: Sun, 29 Mar 2026 19:39:05 -0300 Subject: [PATCH 6/7] feat(audit): admin audit log Audit log - Add audit_log table (migration + schema) with index on created_at. - Add auditLog service (writeAudit, getClientIp) and record events for backups (create, restore, upload-restore, delete, auto-settings), admin actions (users, OIDC, invites, system update, demo baseline, bag tracking, packing template delete, addons), and auth (app settings, MFA enable/disable). - Add GET /api/admin/audit-log with pagination; fix invite insert row id lookup. - Add AuditLogPanel and Admin tab; adminApi.auditLog. - Add admin.tabs.audit and admin.audit.* strings in all locale files. Note: Rebase feature branches so new DB migrations stay after existing ones (e.g. file_links) when merging upstream. --- client/src/api/client.ts | 2 + client/src/components/Admin/AuditLogPanel.tsx | 166 ++++++++++++++++++ client/src/i18n/translations/ar.ts | 13 ++ client/src/i18n/translations/br.ts | 13 ++ client/src/i18n/translations/de.ts | 16 +- client/src/i18n/translations/en.ts | 15 +- client/src/i18n/translations/es.ts | 14 ++ client/src/i18n/translations/fr.ts | 14 ++ client/src/i18n/translations/nl.ts | 14 ++ client/src/i18n/translations/ru.ts | 14 ++ client/src/i18n/translations/zh.ts | 14 ++ client/src/pages/AdminPage.tsx | 4 + server/src/db/migrations.ts | 14 ++ server/src/db/schema.ts | 11 ++ server/src/routes/admin.ts | 147 ++++++++++++++-- server/src/routes/auth.ts | 12 ++ server/src/routes/backup.ts | 53 +++++- server/src/services/auditLog.ts | 30 ++++ 18 files changed, 548 insertions(+), 18 deletions(-) create mode 100644 client/src/components/Admin/AuditLogPanel.tsx create mode 100644 server/src/services/auditLog.ts diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 6726140..41bbcaf 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -163,6 +163,8 @@ export const adminApi = { listInvites: () => apiClient.get('/admin/invites').then(r => r.data), createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data), deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data), + auditLog: (params?: { limit?: number; offset?: number }) => + apiClient.get('/admin/audit-log', { params }).then(r => r.data), } export const addonsApi = { diff --git a/client/src/components/Admin/AuditLogPanel.tsx b/client/src/components/Admin/AuditLogPanel.tsx new file mode 100644 index 0000000..f36d69e --- /dev/null +++ b/client/src/components/Admin/AuditLogPanel.tsx @@ -0,0 +1,166 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { adminApi } from '../../api/client' +import { useTranslation } from '../../i18n' +import { RefreshCw, ClipboardList } from 'lucide-react' + +interface AuditEntry { + id: number + created_at: string + user_id: number | null + username: string | null + user_email: string | null + action: string + resource: string | null + details: Record | null + ip: string | null +} + +export default function AuditLogPanel(): React.ReactElement { + const { t, locale } = useTranslation() + const [entries, setEntries] = useState([]) + const [total, setTotal] = useState(0) + const [offset, setOffset] = useState(0) + const [loading, setLoading] = useState(true) + const limit = 100 + + const loadFirstPage = useCallback(async () => { + setLoading(true) + try { + const data = await adminApi.auditLog({ limit, offset: 0 }) as { + entries: AuditEntry[] + total: number + } + setEntries(data.entries || []) + setTotal(data.total ?? 0) + setOffset(0) + } catch { + setEntries([]) + setTotal(0) + setOffset(0) + } finally { + setLoading(false) + } + }, []) + + const loadMore = useCallback(async () => { + const nextOffset = offset + limit + setLoading(true) + try { + const data = await adminApi.auditLog({ limit, offset: nextOffset }) as { + entries: AuditEntry[] + total: number + } + setEntries((prev) => [...prev, ...(data.entries || [])]) + setTotal(data.total ?? 0) + setOffset(nextOffset) + } catch { + /* keep existing */ + } finally { + setLoading(false) + } + }, [offset]) + + useEffect(() => { + loadFirstPage() + }, [loadFirstPage]) + + const fmtTime = (iso: string) => { + try { + return new Date(iso).toLocaleString(locale, { + dateStyle: 'short', + timeStyle: 'medium', + }) + } catch { + return iso + } + } + + const fmtDetails = (d: Record | null) => { + if (!d || Object.keys(d).length === 0) return '—' + try { + return JSON.stringify(d) + } catch { + return '—' + } + } + + const userLabel = (e: AuditEntry) => { + if (e.username) return e.username + if (e.user_email) return e.user_email + if (e.user_id != null) return `#${e.user_id}` + return '—' + } + + return ( +
+
+
+

+ + {t('admin.tabs.audit')} +

+

{t('admin.audit.subtitle')}

+
+ +
+ +

+ {t('admin.audit.showing', { count: entries.length, total })} +

+ + {loading && entries.length === 0 ? ( +
{t('common.loading')}
+ ) : entries.length === 0 ? ( +
{t('admin.audit.empty')}
+ ) : ( +
+ + + + + + + + + + + + + {entries.map((e) => ( + + + + + + + + + ))} + +
{t('admin.audit.col.time')}{t('admin.audit.col.user')}{t('admin.audit.col.action')}{t('admin.audit.col.resource')}{t('admin.audit.col.ip')}{t('admin.audit.col.details')}
{fmtTime(e.created_at)}{userLabel(e)}{e.action}{e.resource || '—'}{e.ip || '—'}{fmtDetails(e.details)}
+
+ )} + + {entries.length < total && ( + + )} +
+ ) +} diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index e4d5070..dda3574 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -276,6 +276,7 @@ const ar: Record = { 'admin.tabs.users': 'المستخدمون', 'admin.tabs.categories': 'الفئات', 'admin.tabs.backup': 'النسخ الاحتياطي', + 'admin.tabs.audit': 'سجل التدقيق', 'admin.tabs.settings': 'الإعدادات', 'admin.tabs.config': 'الإعدادات', 'admin.tabs.templates': 'قوالب التعبئة', @@ -419,6 +420,18 @@ const ar: Record = { 'admin.weather.locationHint': 'يعتمد الطقس على أول مكان بإحداثيات في كل يوم. إذا لم يكن هناك مكان مخصص ليوم ما، يُستخدم أي مكان من قائمة الأماكن كمرجع.', // GitHub + 'admin.audit.subtitle': 'أحداث الأمان والإدارة (النسخ الاحتياطية، المستخدمون، المصادقة الثنائية، الإعدادات).', + 'admin.audit.empty': 'لا توجد سجلات تدقيق بعد.', + 'admin.audit.refresh': 'تحديث', + 'admin.audit.loadMore': 'تحميل المزيد', + 'admin.audit.showing': 'تم تحميل {count} · الإجمالي {total}', + 'admin.audit.col.time': 'الوقت', + 'admin.audit.col.user': 'المستخدم', + 'admin.audit.col.action': 'الإجراء', + 'admin.audit.col.resource': 'المورد', + 'admin.audit.col.ip': 'عنوان IP', + 'admin.audit.col.details': 'التفاصيل', + 'admin.github.title': 'سجل الإصدارات', 'admin.github.subtitle': 'آخر التحديثات من {repo}', 'admin.github.latest': 'الأحدث', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 969e8a0..5047a61 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -271,6 +271,7 @@ const br: Record = { 'admin.tabs.users': 'Usuários', 'admin.tabs.categories': 'Categorias', 'admin.tabs.backup': 'Backup', + 'admin.tabs.audit': 'Registro de auditoria', 'admin.stats.users': 'Usuários', 'admin.stats.trips': 'Viagens', 'admin.stats.places': 'Lugares', @@ -413,6 +414,18 @@ const br: Record = { // GitHub 'admin.tabs.github': 'GitHub', + + 'admin.audit.subtitle': 'Eventos sensíveis de segurança e administração (backups, usuários, MFA, configurações).', + 'admin.audit.empty': 'Nenhum registro de auditoria ainda.', + 'admin.audit.refresh': 'Atualizar', + 'admin.audit.loadMore': 'Carregar mais', + 'admin.audit.showing': '{count} carregados · {total} no total', + 'admin.audit.col.time': 'Data/hora', + 'admin.audit.col.user': 'Usuário', + 'admin.audit.col.action': 'Ação', + 'admin.audit.col.resource': 'Recurso', + 'admin.audit.col.ip': 'IP', + 'admin.audit.col.details': 'Detalhes', 'admin.github.title': 'Histórico de versões', 'admin.github.subtitle': 'Últimas atualizações de {repo}', 'admin.github.latest': 'Mais recente', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 840a4f2..3f0a2f8 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -271,6 +271,7 @@ const de: Record = { 'admin.tabs.users': 'Benutzer', 'admin.tabs.categories': 'Kategorien', 'admin.tabs.backup': 'Backup', + 'admin.tabs.audit': 'Audit-Protokoll', 'admin.stats.users': 'Benutzer', 'admin.stats.trips': 'Reisen', 'admin.stats.places': 'Orte', @@ -374,8 +375,6 @@ const de: Record = { 'admin.tabs.addons': 'Addons', 'admin.addons.title': 'Addons', 'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um TREK nach deinen Wünschen anzupassen.', - 'admin.addons.catalog.memories.name': 'Erinnerungen', - 'admin.addons.catalog.memories.description': 'Geteilte Fotoalben für jede Reise', 'admin.addons.catalog.packing.name': 'Packliste', 'admin.addons.catalog.packing.description': 'Checklisten zum Kofferpacken für jede Reise', 'admin.addons.catalog.budget.name': 'Budget', @@ -415,6 +414,19 @@ const de: Record = { // GitHub 'admin.tabs.github': 'GitHub', + + 'admin.audit.subtitle': 'Sicherheitsrelevante und administrative Ereignisse (Backups, Benutzer, MFA, Einstellungen).', + 'admin.audit.empty': 'Noch keine Audit-Einträge.', + 'admin.audit.refresh': 'Aktualisieren', + 'admin.audit.loadMore': 'Mehr laden', + 'admin.audit.showing': '{count} geladen · {total} gesamt', + 'admin.audit.col.time': 'Zeit', + 'admin.audit.col.user': 'Benutzer', + 'admin.audit.col.action': 'Aktion', + 'admin.audit.col.resource': 'Ressource', + 'admin.audit.col.ip': 'IP', + 'admin.audit.col.details': 'Details', + 'admin.github.title': 'Update-Verlauf', 'admin.github.subtitle': 'Neueste Updates von {repo}', 'admin.github.latest': 'Aktuell', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 18d79ac..ee5f058 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -271,6 +271,7 @@ const en: Record = { 'admin.tabs.users': 'Users', 'admin.tabs.categories': 'Categories', 'admin.tabs.backup': 'Backup', + 'admin.tabs.audit': 'Audit log', 'admin.stats.users': 'Users', 'admin.stats.trips': 'Trips', 'admin.stats.places': 'Places', @@ -374,8 +375,6 @@ const en: Record = { 'admin.tabs.addons': 'Addons', 'admin.addons.title': 'Addons', 'admin.addons.subtitle': 'Enable or disable features to customize your TREK experience.', - 'admin.addons.catalog.memories.name': 'Memories', - 'admin.addons.catalog.memories.description': 'Shared photo albums for each trip', 'admin.addons.catalog.packing.name': 'Packing', 'admin.addons.catalog.packing.description': 'Checklists to prepare your luggage for each trip', 'admin.addons.catalog.budget.name': 'Budget', @@ -415,6 +414,18 @@ const en: Record = { // GitHub 'admin.tabs.github': 'GitHub', + + 'admin.audit.subtitle': 'Security-sensitive and administration events (backups, users, MFA, settings).', + 'admin.audit.empty': 'No audit entries yet.', + 'admin.audit.refresh': 'Refresh', + 'admin.audit.loadMore': 'Load more', + 'admin.audit.showing': '{count} loaded · {total} total', + 'admin.audit.col.time': 'Time', + 'admin.audit.col.user': 'User', + 'admin.audit.col.action': 'Action', + 'admin.audit.col.resource': 'Resource', + 'admin.audit.col.ip': 'IP', + 'admin.audit.col.details': 'Details', 'admin.github.title': 'Release History', 'admin.github.subtitle': 'Latest updates from {repo}', 'admin.github.latest': 'Latest', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 3cb293f..34f6979 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -269,6 +269,7 @@ const es: Record = { 'admin.tabs.users': 'Usuarios', 'admin.tabs.categories': 'Categorías', 'admin.tabs.backup': 'Copia de seguridad', + 'admin.tabs.audit': 'Registro de auditoría', 'admin.stats.users': 'Usuarios', 'admin.stats.trips': 'Viajes', 'admin.stats.places': 'Lugares', @@ -393,6 +394,19 @@ const es: Record = { // GitHub 'admin.tabs.github': 'GitHub', + + 'admin.audit.subtitle': 'Eventos sensibles de seguridad y administración (copias de seguridad, usuarios, MFA, ajustes).', + 'admin.audit.empty': 'Aún no hay entradas de auditoría.', + 'admin.audit.refresh': 'Actualizar', + 'admin.audit.loadMore': 'Cargar más', + 'admin.audit.showing': '{count} cargados · {total} en total', + 'admin.audit.col.time': 'Fecha y hora', + 'admin.audit.col.user': 'Usuario', + 'admin.audit.col.action': 'Acción', + 'admin.audit.col.resource': 'Recurso', + 'admin.audit.col.ip': 'IP', + 'admin.audit.col.details': 'Detalles', + 'admin.github.title': 'Historial de versiones', 'admin.github.subtitle': 'Últimas novedades de {repo}', 'admin.github.latest': 'Última', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 28a7462..fef5c43 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -271,6 +271,7 @@ const fr: Record = { 'admin.tabs.users': 'Utilisateurs', 'admin.tabs.categories': 'Catégories', 'admin.tabs.backup': 'Sauvegarde', + 'admin.tabs.audit': 'Journal d\'audit', 'admin.stats.users': 'Utilisateurs', 'admin.stats.trips': 'Voyages', 'admin.stats.places': 'Lieux', @@ -412,6 +413,19 @@ const fr: Record = { // GitHub 'admin.tabs.github': 'GitHub', + + 'admin.audit.subtitle': 'Événements liés à la sécurité et à l\'administration (sauvegardes, utilisateurs, MFA, paramètres).', + 'admin.audit.empty': 'Aucune entrée d\'audit pour le moment.', + 'admin.audit.refresh': 'Actualiser', + 'admin.audit.loadMore': 'Charger plus', + 'admin.audit.showing': '{count} chargés · {total} au total', + 'admin.audit.col.time': 'Date et heure', + 'admin.audit.col.user': 'Utilisateur', + 'admin.audit.col.action': 'Action', + 'admin.audit.col.resource': 'Ressource', + 'admin.audit.col.ip': 'IP', + 'admin.audit.col.details': 'Détails', + 'admin.github.title': 'Historique des versions', 'admin.github.subtitle': 'Dernières mises à jour de {repo}', 'admin.github.latest': 'Dernière', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index add8dd6..8e35a4e 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -271,6 +271,7 @@ const nl: Record = { 'admin.tabs.users': 'Gebruikers', 'admin.tabs.categories': 'Categorieën', 'admin.tabs.backup': 'Back-up', + 'admin.tabs.audit': 'Auditlog', 'admin.stats.users': 'Gebruikers', 'admin.stats.trips': 'Reizen', 'admin.stats.places': 'Plaatsen', @@ -412,6 +413,19 @@ const nl: Record = { // GitHub 'admin.tabs.github': 'GitHub', + + 'admin.audit.subtitle': 'Beveiligingsgevoelige en beheerdersgebeurtenissen (back-ups, gebruikers, MFA, instellingen).', + 'admin.audit.empty': 'Nog geen auditregistraties.', + 'admin.audit.refresh': 'Vernieuwen', + 'admin.audit.loadMore': 'Meer laden', + 'admin.audit.showing': '{count} geladen · {total} totaal', + 'admin.audit.col.time': 'Tijd', + 'admin.audit.col.user': 'Gebruiker', + 'admin.audit.col.action': 'Actie', + 'admin.audit.col.resource': 'Bron', + 'admin.audit.col.ip': 'IP', + 'admin.audit.col.details': 'Details', + 'admin.github.title': 'Release-geschiedenis', 'admin.github.subtitle': 'Laatste updates van {repo}', 'admin.github.latest': 'Nieuwste', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 039f8dc..660bb29 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -271,6 +271,7 @@ const ru: Record = { 'admin.tabs.users': 'Пользователи', 'admin.tabs.categories': 'Категории', 'admin.tabs.backup': 'Резервная копия', + 'admin.tabs.audit': 'Журнал аудита', 'admin.stats.users': 'Пользователи', 'admin.stats.trips': 'Поездки', 'admin.stats.places': 'Места', @@ -412,6 +413,19 @@ const ru: Record = { // GitHub 'admin.tabs.github': 'GitHub', + + 'admin.audit.subtitle': 'События, связанные с безопасностью и администрированием (резервные копии, пользователи, MFA, настройки).', + 'admin.audit.empty': 'Записей аудита пока нет.', + 'admin.audit.refresh': 'Обновить', + 'admin.audit.loadMore': 'Загрузить ещё', + 'admin.audit.showing': 'Загружено: {count} · всего {total}', + 'admin.audit.col.time': 'Время', + 'admin.audit.col.user': 'Пользователь', + 'admin.audit.col.action': 'Действие', + 'admin.audit.col.resource': 'Объект', + 'admin.audit.col.ip': 'IP', + 'admin.audit.col.details': 'Подробности', + 'admin.github.title': 'История релизов', 'admin.github.subtitle': 'Последние обновления из {repo}', 'admin.github.latest': 'Последний', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 6da7988..7be0f3d 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -271,6 +271,7 @@ const zh: Record = { 'admin.tabs.users': '用户', 'admin.tabs.categories': '分类', 'admin.tabs.backup': '备份', + 'admin.tabs.audit': '审计日志', 'admin.stats.users': '用户', 'admin.stats.trips': '旅行', 'admin.stats.places': '地点', @@ -412,6 +413,19 @@ const zh: Record = { // GitHub 'admin.tabs.github': 'GitHub', + + 'admin.audit.subtitle': '安全与管理员操作记录(备份、用户、MFA、设置)。', + 'admin.audit.empty': '暂无审计记录。', + 'admin.audit.refresh': '刷新', + 'admin.audit.loadMore': '加载更多', + 'admin.audit.showing': '已加载 {count} 条 · 共 {total} 条', + 'admin.audit.col.time': '时间', + 'admin.audit.col.user': '用户', + 'admin.audit.col.action': '操作', + 'admin.audit.col.resource': '资源', + 'admin.audit.col.ip': 'IP', + 'admin.audit.col.details': '详情', + 'admin.github.title': '版本历史', 'admin.github.subtitle': '{repo} 的最新更新', 'admin.github.latest': '最新', diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index b63306f..5e2b007 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -13,6 +13,7 @@ import BackupPanel from '../components/Admin/BackupPanel' import GitHubPanel from '../components/Admin/GitHubPanel' import AddonManager from '../components/Admin/AddonManager' import PackingTemplateManager from '../components/Admin/PackingTemplateManager' +import AuditLogPanel from '../components/Admin/AuditLogPanel' import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun, Link2, Copy, Plus } from 'lucide-react' import CustomSelect from '../components/shared/CustomSelect' @@ -61,6 +62,7 @@ export default function AdminPage(): React.ReactElement { { id: 'addons', label: t('admin.tabs.addons') }, { id: 'settings', label: t('admin.tabs.settings') }, { id: 'backup', label: t('admin.tabs.backup') }, + { id: 'audit', label: t('admin.tabs.audit') }, { id: 'github', label: t('admin.tabs.github') }, ] @@ -923,6 +925,8 @@ export default function AdminPage(): React.ReactElement { {activeTab === 'backup' && } + {activeTab === 'audit' && } + {activeTab === 'github' && } diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 55078fd..7d59ef5 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -321,6 +321,20 @@ function runMigrations(db: Database.Database): void { UNIQUE(file_id, place_id) )`); }, + () => { + db.exec(` + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + resource TEXT, + details TEXT, + ip TEXT + ); + CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC); + `); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 425a914..c3ebe23 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -380,6 +380,17 @@ function createTables(db: Database.Database): void { UNIQUE(assignment_id, user_id) ); CREATE INDEX IF NOT EXISTS idx_assignment_participants_assignment ON assignment_participants(assignment_id); + + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + resource TEXT, + details TEXT, + ip TEXT + ); + CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC); `); } diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index c7aaf2a..7879c78 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -7,6 +7,7 @@ import fs from 'fs'; import { db } from '../db/database'; import { authenticate, adminOnly } from '../middleware/auth'; import { AuthRequest, User, Addon } from '../types'; +import { writeAudit, getClientIp } from '../services/auditLog'; const router = express.Router(); @@ -52,6 +53,14 @@ router.post('/users', (req: Request, res: Response) => { 'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?' ).get(result.lastInsertRowid); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.user_create', + resource: String(result.lastInsertRowid), + ip: getClientIp(req), + details: { username: username.trim(), email: email.trim(), role: role || 'user' }, + }); res.status(201).json({ user }); }); @@ -90,6 +99,19 @@ router.put('/users/:id', (req: Request, res: Response) => { 'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?' ).get(req.params.id); + const authReq = req as AuthRequest; + const changed: string[] = []; + if (username) changed.push('username'); + if (email) changed.push('email'); + if (role) changed.push('role'); + if (password) changed.push('password'); + writeAudit({ + userId: authReq.user.id, + action: 'admin.user_update', + resource: String(req.params.id), + ip: getClientIp(req), + details: { fields: changed }, + }); res.json({ user: updated }); }); @@ -103,6 +125,12 @@ router.delete('/users/:id', (req: Request, res: Response) => { if (!user) return res.status(404).json({ error: 'User not found' }); db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id); + writeAudit({ + userId: authReq.user.id, + action: 'admin.user_delete', + resource: String(req.params.id), + ip: getClientIp(req), + }); res.json({ success: true }); }); @@ -115,6 +143,48 @@ router.get('/stats', (_req: Request, res: Response) => { res.json({ totalUsers, totalTrips, totalPlaces, totalFiles }); }); +router.get('/audit-log', (req: Request, res: Response) => { + const limitRaw = parseInt(String(req.query.limit || '100'), 10); + const offsetRaw = parseInt(String(req.query.offset || '0'), 10); + const limit = Math.min(Math.max(Number.isFinite(limitRaw) ? limitRaw : 100, 1), 500); + const offset = Math.max(Number.isFinite(offsetRaw) ? offsetRaw : 0, 0); + type Row = { + id: number; + created_at: string; + user_id: number | null; + username: string | null; + user_email: string | null; + action: string; + resource: string | null; + details: string | null; + ip: string | null; + }; + const rows = db.prepare(` + SELECT a.id, a.created_at, a.user_id, u.username, u.email as user_email, a.action, a.resource, a.details, a.ip + FROM audit_log a + LEFT JOIN users u ON u.id = a.user_id + ORDER BY a.id DESC + LIMIT ? OFFSET ? + `).all(limit, offset) as Row[]; + const total = (db.prepare('SELECT COUNT(*) as c FROM audit_log').get() as { c: number }).c; + res.json({ + entries: rows.map((r) => { + let details: Record | null = null; + if (r.details) { + try { + details = JSON.parse(r.details) as Record; + } catch { + details = { _parse_error: true }; + } + } + return { ...r, details }; + }), + total, + limit, + offset, + }); +}); + router.get('/oidc', (_req: Request, res: Response) => { const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || ''; const secret = get('oidc_client_secret'); @@ -135,16 +205,25 @@ router.put('/oidc', (req: Request, res: Response) => { if (client_secret !== undefined) set('oidc_client_secret', client_secret); set('oidc_display_name', display_name); set('oidc_only', oidc_only ? 'true' : 'false'); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.oidc_update', + ip: getClientIp(req), + details: { oidc_only: !!oidc_only, issuer_set: !!issuer }, + }); res.json({ success: true }); }); -router.post('/save-demo-baseline', (_req: Request, res: Response) => { +router.post('/save-demo-baseline', (req: Request, res: Response) => { if (process.env.DEMO_MODE !== 'true') { return res.status(404).json({ error: 'Not found' }); } try { const { saveBaseline } = require('../demo/demo-reset'); saveBaseline(); + const authReq = req as AuthRequest; + writeAudit({ userId: authReq.user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) }); res.json({ success: true, message: 'Demo baseline saved. Hourly resets will restore to this state.' }); } catch (err: unknown) { console.error(err); @@ -201,7 +280,7 @@ router.get('/version-check', async (_req: Request, res: Response) => { } }); -router.post('/update', async (_req: Request, res: Response) => { +router.post('/update', async (req: Request, res: Response) => { const rootDir = path.resolve(__dirname, '../../..'); const serverDir = path.resolve(__dirname, '../..'); const clientDir = path.join(rootDir, 'client'); @@ -224,6 +303,13 @@ router.post('/update', async (_req: Request, res: Response) => { const { version: newVersion } = require('../../package.json'); steps.push({ step: 'version', version: newVersion }); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.system_update', + resource: newVersion, + ip: getClientIp(req), + }); res.json({ success: true, steps, restarting: true }); setTimeout(() => { @@ -260,24 +346,39 @@ router.post('/invites', (req: Request, res: Response) => { ? new Date(Date.now() + parseInt(expires_in_days) * 86400000).toISOString() : null; - db.prepare( + const ins = db.prepare( 'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by) VALUES (?, ?, ?, ?)' ).run(token, uses, expiresAt, authReq.user.id); + const inviteId = Number(ins.lastInsertRowid); const invite = db.prepare(` SELECT i.*, u.username as created_by_name FROM invite_tokens i JOIN users u ON i.created_by = u.id - WHERE i.id = last_insert_rowid() - `).get(); + WHERE i.id = ? + `).get(inviteId); + writeAudit({ + userId: authReq.user.id, + action: 'admin.invite_create', + resource: String(inviteId), + ip: getClientIp(req), + details: { max_uses: uses, expires_in_days: expires_in_days ?? null }, + }); res.status(201).json({ invite }); }); -router.delete('/invites/:id', (_req: Request, res: Response) => { - const invite = db.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(_req.params.id); +router.delete('/invites/:id', (req: Request, res: Response) => { + const invite = db.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(req.params.id); if (!invite) return res.status(404).json({ error: 'Invite not found' }); - db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(_req.params.id); + db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(req.params.id); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.invite_delete', + resource: String(req.params.id), + ip: getClientIp(req), + }); res.json({ success: true }); }); @@ -291,6 +392,13 @@ router.get('/bag-tracking', (_req: Request, res: Response) => { router.put('/bag-tracking', (req: Request, res: Response) => { const { enabled } = req.body; db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(enabled ? 'true' : 'false'); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.bag_tracking', + ip: getClientIp(req), + details: { enabled: !!enabled }, + }); res.json({ enabled: !!enabled }); }); @@ -337,10 +445,19 @@ router.put('/packing-templates/:id', (req: Request, res: Response) => { res.json({ template: db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id) }); }); -router.delete('/packing-templates/:id', (_req: Request, res: Response) => { - const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(_req.params.id); +router.delete('/packing-templates/:id', (req: Request, res: Response) => { + const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id); if (!template) return res.status(404).json({ error: 'Template not found' }); - db.prepare('DELETE FROM packing_templates WHERE id = ?').run(_req.params.id); + db.prepare('DELETE FROM packing_templates WHERE id = ?').run(req.params.id); + const authReq = req as AuthRequest; + const t = template as { name?: string }; + writeAudit({ + userId: authReq.user.id, + action: 'admin.packing_template_delete', + resource: String(req.params.id), + ip: getClientIp(req), + details: { name: t.name }, + }); res.json({ success: true }); }); @@ -408,6 +525,14 @@ router.put('/addons/:id', (req: Request, res: Response) => { if (enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, req.params.id); if (config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(config), req.params.id); const updated = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id) as Addon; + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.addon_update', + resource: String(req.params.id), + ip: getClientIp(req), + details: { enabled: enabled !== undefined ? !!enabled : undefined, config_changed: config !== undefined }, + }); res.json({ addon: { ...updated, enabled: !!updated.enabled, config: JSON.parse(updated.config || '{}') } }); }); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index b5518ef..f752269 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -13,6 +13,7 @@ import { authenticate, demoUploadBlock } from '../middleware/auth'; import { JWT_SECRET } from '../config'; import { encryptMfaSecret, decryptMfaSecret } from '../services/mfaCrypto'; import { AuthRequest, User } from '../types'; +import { writeAudit, getClientIp } from '../services/auditLog'; authenticator.options = { window: 1 }; @@ -518,6 +519,15 @@ router.put('/app-settings', authenticate, (req: Request, res: Response) => { if (allowed_file_types !== undefined) { db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', ?)").run(String(allowed_file_types)); } + writeAudit({ + userId: authReq.user.id, + action: 'settings.app_update', + ip: getClientIp(req), + details: { + allow_registration: allow_registration !== undefined ? Boolean(allow_registration) : undefined, + allowed_file_types_changed: allowed_file_types !== undefined, + }, + }); res.json({ success: true }); }); @@ -673,6 +683,7 @@ router.post('/mfa/enable', authenticate, (req: Request, res: Response) => { authReq.user.id ); mfaSetupPending.delete(authReq.user.id); + writeAudit({ userId: authReq.user.id, action: 'user.mfa_enable', ip: getClientIp(req) }); res.json({ success: true, mfa_enabled: true }); }); @@ -702,6 +713,7 @@ router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (re authReq.user.id ); mfaSetupPending.delete(authReq.user.id); + writeAudit({ userId: authReq.user.id, action: 'user.mfa_disable', ip: getClientIp(req) }); res.json({ success: true, mfa_enabled: false }); }); diff --git a/server/src/routes/backup.ts b/server/src/routes/backup.ts index e18190d..06a7ead 100644 --- a/server/src/routes/backup.ts +++ b/server/src/routes/backup.ts @@ -7,6 +7,10 @@ import fs from 'fs'; import { authenticate, adminOnly } from '../middleware/auth'; import * as scheduler from '../scheduler'; import { db, closeDb, reinitialize } from '../db/database'; +import { AuthRequest } from '../types'; +import { writeAudit, getClientIp } from '../services/auditLog'; + +type RestoreAuditInfo = { userId: number; ip: string | null; source: 'backup.restore' | 'backup.upload_restore'; label: string }; const router = express.Router(); @@ -103,6 +107,14 @@ router.post('/create', backupRateLimiter(3, BACKUP_RATE_WINDOW), async (_req: Re }); const stat = fs.statSync(outputPath); + const authReq = _req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'backup.create', + resource: filename, + ip: getClientIp(_req), + details: { size: stat.size }, + }); res.json({ success: true, backup: { @@ -134,7 +146,7 @@ router.get('/download/:filename', (req: Request, res: Response) => { res.download(filePath, filename); }); -async function restoreFromZip(zipPath: string, res: Response) { +async function restoreFromZip(zipPath: string, res: Response, audit?: RestoreAuditInfo) { const extractDir = path.join(dataDir, `restore-${Date.now()}`); try { await fs.createReadStream(zipPath) @@ -174,6 +186,14 @@ async function restoreFromZip(zipPath: string, res: Response) { fs.rmSync(extractDir, { recursive: true, force: true }); + if (audit) { + writeAudit({ + userId: audit.userId, + action: audit.source, + resource: audit.label, + ip: audit.ip, + }); + } res.json({ success: true }); } catch (err: unknown) { console.error('Restore error:', err); @@ -191,7 +211,13 @@ router.post('/restore/:filename', async (req: Request, res: Response) => { if (!fs.existsSync(zipPath)) { return res.status(404).json({ error: 'Backup not found' }); } - await restoreFromZip(zipPath, res); + const authReq = req as AuthRequest; + await restoreFromZip(zipPath, res, { + userId: authReq.user.id, + ip: getClientIp(req), + source: 'backup.restore', + label: filename, + }); }); const uploadTmp = multer({ @@ -206,7 +232,14 @@ const uploadTmp = multer({ router.post('/upload-restore', uploadTmp.single('backup'), async (req: Request, res: Response) => { if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); const zipPath = req.file.path; - await restoreFromZip(zipPath, res); + const authReq = req as AuthRequest; + const origName = req.file.originalname || 'upload.zip'; + await restoreFromZip(zipPath, res, { + userId: authReq.user.id, + ip: getClientIp(req), + source: 'backup.upload_restore', + label: origName, + }); if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath); }); @@ -248,6 +281,13 @@ router.put('/auto-settings', (req: Request, res: Response) => { const settings = parseAutoBackupBody((req.body || {}) as Record); scheduler.saveSettings(settings); scheduler.start(); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'backup.auto_settings', + ip: getClientIp(req), + details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days }, + }); res.json({ settings }); } catch (err: unknown) { console.error('[backup] PUT auto-settings:', err); @@ -272,6 +312,13 @@ router.delete('/:filename', (req: Request, res: Response) => { } fs.unlinkSync(filePath); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'backup.delete', + resource: filename, + ip: getClientIp(req), + }); res.json({ success: true }); }); diff --git a/server/src/services/auditLog.ts b/server/src/services/auditLog.ts new file mode 100644 index 0000000..ed78ad5 --- /dev/null +++ b/server/src/services/auditLog.ts @@ -0,0 +1,30 @@ +import { Request } from 'express'; +import { db } from '../db/database'; + +export function getClientIp(req: Request): string | null { + const xff = req.headers['x-forwarded-for']; + if (typeof xff === 'string') { + const first = xff.split(',')[0]?.trim(); + return first || null; + } + if (Array.isArray(xff) && xff[0]) return String(xff[0]).trim() || null; + return req.socket?.remoteAddress || null; +} + +/** Best-effort; never throws — failures are logged only. */ +export function writeAudit(entry: { + userId: number | null; + action: string; + resource?: string | null; + details?: Record; + ip?: string | null; +}): void { + try { + const detailsJson = entry.details && Object.keys(entry.details).length > 0 ? JSON.stringify(entry.details) : null; + db.prepare( + `INSERT INTO audit_log (user_id, action, resource, details, ip) VALUES (?, ?, ?, ?, ?)` + ).run(entry.userId, entry.action, entry.resource ?? null, detailsJson, entry.ip ?? null); + } catch (e) { + console.error('[audit] write failed:', e instanceof Error ? e.message : e); + } +} From 10ebf46a98e722d43338ab5fab38fc61907a2577 Mon Sep 17 00:00:00 2001 From: fgbona Date: Mon, 30 Mar 2026 13:19:01 -0300 Subject: [PATCH 7/7] harden runtime config and automate first-run permissions Run the container as a non-root user in production to fail fast on insecure deployments. Add DEBUG env-based request/response logging for container diagnostics, and introduce a one-shot init-permissions service in docker-compose so fresh installs automatically fix data/uploads ownership for SQLite write access. --- Dockerfile | 3 +++ docker-compose.yml | 16 ++++++++++++++++ server/.env.example | 1 + server/src/index.ts | 30 ++++++++++++++++++++++++++++++ 4 files changed, 50 insertions(+) diff --git a/Dockerfile b/Dockerfile index b45bd59..7a19f74 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,9 @@ COPY --from=client-builder /app/client/public/fonts ./public/fonts RUN mkdir -p /app/data /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \ mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data +RUN chown -R node:node /app +USER node + # Umgebung setzen ENV NODE_ENV=production ENV PORT=3000 diff --git a/docker-compose.yml b/docker-compose.yml index 1acc607..141f7ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,23 @@ services: + init-permissions: + image: alpine:3.20 + container_name: trek-init-permissions + user: "0:0" + command: > + sh -c "mkdir -p /app/data /app/uploads && + chown -R 1000:1000 /app/data /app/uploads && + chmod -R u+rwX /app/data /app/uploads" + volumes: + - ./data:/app/data + - ./uploads:/app/uploads + restart: "no" + app: image: mauriceboe/trek:latest container_name: trek + depends_on: + init-permissions: + condition: service_completed_successfully ports: - "3000:3000" environment: diff --git a/server/.env.example b/server/.env.example index 188bf55..4e2e99e 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,3 +1,4 @@ PORT=3001 JWT_SECRET=your-super-secret-jwt-key-change-in-production NODE_ENV=development +DEBUG=false diff --git a/server/src/index.ts b/server/src/index.ts index c53977f..666feb0 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -6,6 +6,7 @@ import path from 'path'; import fs from 'fs'; const app = express(); +const DEBUG = String(process.env.DEBUG || 'false').toLowerCase() === 'true'; // Trust first proxy (nginx/Docker) for correct req.ip if (process.env.NODE_ENV === 'production' || process.env.TRUST_PROXY) { @@ -79,6 +80,34 @@ if (shouldForceHttps) { app.use(express.json({ limit: '100kb' })); app.use(express.urlencoded({ extended: true })); +if (DEBUG) { + app.use((req: Request, res: Response, next: NextFunction) => { + const startedAt = Date.now(); + const requestId = Math.random().toString(36).slice(2, 10); + const redact = (value: unknown): unknown => { + if (!value || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map(redact); + const hidden = new Set(['password', 'token', 'jwt', 'authorization', 'cookie', 'client_secret', 'mfa_token', 'code']); + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = hidden.has(k.toLowerCase()) ? '[REDACTED]' : redact(v); + } + return out; + }; + + const safeQuery = redact(req.query); + const safeBody = redact(req.body); + console.log(`[DEBUG][REQ ${requestId}] ${req.method} ${req.originalUrl} ip=${req.ip} query=${JSON.stringify(safeQuery)} body=${JSON.stringify(safeBody)}`); + + res.on('finish', () => { + const elapsedMs = Date.now() - startedAt; + console.log(`[DEBUG][RES ${requestId}] ${req.method} ${req.originalUrl} status=${res.statusCode} elapsed_ms=${elapsedMs}`); + }); + + next(); + }); +} + // Avatars are public (shown on login, sharing screens) app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars'))); @@ -181,6 +210,7 @@ const PORT = process.env.PORT || 3001; const server = app.listen(PORT, () => { console.log(`TREK API running on port ${PORT}`); console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); + console.log(`Debug logs: ${DEBUG ? 'ENABLED' : 'disabled'}`); if (process.env.DEMO_MODE === 'true') console.log('Demo mode: ENABLED'); if (process.env.DEMO_MODE === 'true' && process.env.NODE_ENV === 'production') { console.warn('[SECURITY WARNING] DEMO_MODE is enabled in production! Demo credentials are publicly exposed.');