diff --git a/Dockerfile b/Dockerfile
index b45bd59..262571c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -11,9 +11,9 @@ FROM node:22-alpine
WORKDIR /app
-# Server-Dependencies installieren (better-sqlite3 braucht Build-Tools)
+# Timezone support + Server-Dependencies (better-sqlite3 braucht Build-Tools)
COPY server/package*.json ./
-RUN apk add --no-cache python3 make g++ && \
+RUN apk add --no-cache tzdata python3 make g++ && \
npm ci --production && \
apk del python3 make g++
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.
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
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 8d2c96b..8eba608 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -62,28 +62,26 @@ function RootRedirect() {
}
export default function App() {
- const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey } = useAuthStore()
+ const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone } = useAuthStore()
const { loadSettings } = useSettingsStore()
useEffect(() => {
if (token) {
loadUser()
}
- authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string }) => {
+ authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string }) => {
if (config?.demo_mode) setDemoMode(true)
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
+ if (config?.timezone) setServerTimezone(config.timezone)
- // Version-based cache invalidation: clear all caches on version change
if (config?.version) {
const storedVersion = localStorage.getItem('trek_app_version')
if (storedVersion && storedVersion !== config.version) {
try {
- // Clear all Service Worker caches
if ('caches' in window) {
const names = await caches.keys()
await Promise.all(names.map(n => caches.delete(n)))
}
- // Unregister all service workers
if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations()
await Promise.all(regs.map(r => r.unregister()))
diff --git a/client/src/components/Admin/BackupPanel.tsx b/client/src/components/Admin/BackupPanel.tsx
index 89af898..fb62ed9 100644
--- a/client/src/components/Admin/BackupPanel.tsx
+++ b/client/src/components/Admin/BackupPanel.tsx
@@ -3,6 +3,7 @@ import { backupApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
+import { useSettingsStore } from '../../store/settingsStore'
import { getApiErrorMessage } from '../../types'
const INTERVAL_OPTIONS = [
@@ -21,19 +22,35 @@ const KEEP_OPTIONS = [
{ value: 0, labelKey: 'backup.keep.forever' },
]
+const DAYS_OF_WEEK = [
+ { value: 0, labelKey: 'backup.dow.sunday' },
+ { value: 1, labelKey: 'backup.dow.monday' },
+ { value: 2, labelKey: 'backup.dow.tuesday' },
+ { value: 3, labelKey: 'backup.dow.wednesday' },
+ { value: 4, labelKey: 'backup.dow.thursday' },
+ { value: 5, labelKey: 'backup.dow.friday' },
+ { value: 6, labelKey: 'backup.dow.saturday' },
+]
+
+const HOURS = Array.from({ length: 24 }, (_, i) => i)
+
+const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1)
+
export default function BackupPanel() {
const [backups, setBackups] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const [restoringFile, setRestoringFile] = useState(null)
const [isUploading, setIsUploading] = useState(false)
- const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 })
+ const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 })
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
+ const [serverTimezone, setServerTimezone] = useState('')
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
const fileInputRef = useRef(null)
const toast = useToast()
const { t, language, locale } = useTranslation()
+ const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const loadBackups = async () => {
setIsLoading(true)
@@ -51,6 +68,7 @@ export default function BackupPanel() {
try {
const data = await backupApi.getAutoSettings()
setAutoSettings(data.settings)
+ if (data.timezone) setServerTimezone(data.timezone)
} catch {}
}
@@ -147,10 +165,12 @@ export default function BackupPanel() {
const formatDate = (dateStr) => {
if (!dateStr) return '-'
try {
- return new Date(dateStr).toLocaleString(locale, {
+ const opts: Intl.DateTimeFormatOptions = {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
- })
+ }
+ if (serverTimezone) opts.timeZone = serverTimezone
+ return new Date(dateStr).toLocaleString(locale, opts)
} catch { return dateStr }
}
@@ -331,6 +351,76 @@ export default function BackupPanel() {
+ {/* Hour picker (for daily, weekly, monthly) */}
+ {autoSettings.interval !== 'hourly' && (
+
+
+
+
+ {t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''}
+
+
+ )}
+
+ {/* Day of week (for weekly) */}
+ {autoSettings.interval === 'weekly' && (
+
+
+
+ {DAYS_OF_WEEK.map(opt => (
+
+ ))}
+
+
+ )}
+
+ {/* Day of month (for monthly) */}
+ {autoSettings.interval === 'monthly' && (
+
+
+
+
{t('backup.auto.dayOfMonthHint')}
+
+ )}
+
{/* Keep duration */}
diff --git a/client/src/components/Vacay/VacayMonthCard.tsx b/client/src/components/Vacay/VacayMonthCard.tsx
index 9c1e8ad..cedf7d9 100644
--- a/client/src/components/Vacay/VacayMonthCard.tsx
+++ b/client/src/components/Vacay/VacayMonthCard.tsx
@@ -6,10 +6,15 @@ 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_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 {
@@ -37,9 +42,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 === '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)
const daysInMonth = new Date(year, month + 1, 0).getDate()
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
diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts
index 06fbec0..ab7401f 100644
--- a/client/src/i18n/translations/de.ts
+++ b/client/src/i18n/translations/de.ts
@@ -1022,7 +1022,27 @@ const de: Record = {
'backup.auto.enable': 'Auto-Backup aktivieren',
'backup.auto.enableHint': 'Backups werden automatisch nach dem gewählten Zeitplan erstellt',
'backup.auto.interval': 'Intervall',
+ 'backup.auto.hour': 'Ausführung um',
+ 'backup.auto.hourHint': 'Lokale Serverzeit ({format}-Format)',
+ 'backup.auto.dayOfWeek': 'Wochentag',
+ 'backup.auto.dayOfMonth': 'Tag des Monats',
+ 'backup.auto.dayOfMonthHint': 'Auf 1–28 beschränkt, um mit allen Monaten kompatibel zu sein',
+ 'backup.auto.scheduleSummary': 'Zeitplan',
+ 'backup.auto.summaryDaily': 'Täglich um {hour}:00',
+ 'backup.auto.summaryWeekly': 'Jeden {day} um {hour}:00',
+ 'backup.auto.summaryMonthly': 'Am {day}. jedes Monats um {hour}:00',
+ 'backup.auto.envLocked': 'Docker',
+ 'backup.auto.envLockedHint': 'Auto-Backup wird über Docker-Umgebungsvariablen konfiguriert. Ändern Sie Ihre docker-compose.yml und starten Sie den Container neu.',
+ 'backup.auto.copyEnv': 'Docker-Umgebungsvariablen kopieren',
+ 'backup.auto.envCopied': 'Docker-Umgebungsvariablen in die Zwischenablage kopiert',
'backup.auto.keepLabel': 'Alte Backups löschen nach',
+ 'backup.dow.sunday': 'So',
+ 'backup.dow.monday': 'Mo',
+ 'backup.dow.tuesday': 'Di',
+ 'backup.dow.wednesday': 'Mi',
+ 'backup.dow.thursday': 'Do',
+ 'backup.dow.friday': 'Fr',
+ 'backup.dow.saturday': 'Sa',
'backup.interval.hourly': 'Stündlich',
'backup.interval.daily': 'Täglich',
'backup.interval.weekly': 'Wöchentlich',
diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts
index b34fe41..f198997 100644
--- a/client/src/i18n/translations/en.ts
+++ b/client/src/i18n/translations/en.ts
@@ -1022,7 +1022,27 @@ const en: Record = {
'backup.auto.enable': 'Enable auto-backup',
'backup.auto.enableHint': 'Backups will be created automatically on the chosen schedule',
'backup.auto.interval': 'Interval',
+ 'backup.auto.hour': 'Run at hour',
+ 'backup.auto.hourHint': 'Server local time ({format} format)',
+ 'backup.auto.dayOfWeek': 'Day of week',
+ 'backup.auto.dayOfMonth': 'Day of month',
+ 'backup.auto.dayOfMonthHint': 'Limited to 1–28 for compatibility with all months',
+ 'backup.auto.scheduleSummary': 'Schedule',
+ 'backup.auto.summaryDaily': 'Every day at {hour}:00',
+ 'backup.auto.summaryWeekly': 'Every {day} at {hour}:00',
+ 'backup.auto.summaryMonthly': 'Day {day} of every month at {hour}:00',
+ 'backup.auto.envLocked': 'Docker',
+ 'backup.auto.envLockedHint': 'Auto-backup is configured via Docker environment variables. To change these settings, update your docker-compose.yml and restart the container.',
+ 'backup.auto.copyEnv': 'Copy Docker env vars',
+ 'backup.auto.envCopied': 'Docker env vars copied to clipboard',
'backup.auto.keepLabel': 'Delete old backups after',
+ 'backup.dow.sunday': 'Sun',
+ 'backup.dow.monday': 'Mon',
+ 'backup.dow.tuesday': 'Tue',
+ 'backup.dow.wednesday': 'Wed',
+ 'backup.dow.thursday': 'Thu',
+ 'backup.dow.friday': 'Fri',
+ 'backup.dow.saturday': 'Sat',
'backup.interval.hourly': 'Hourly',
'backup.interval.daily': 'Daily',
'backup.interval.weekly': 'Weekly',
diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts
index 7266922..fe01d4d 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',
@@ -187,15 +187,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',
@@ -210,7 +210,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',
@@ -261,7 +261,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',
@@ -344,7 +344,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',
@@ -382,11 +382,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': '.',
@@ -409,7 +409,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',
@@ -420,8 +420,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',
@@ -431,7 +431,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…',
@@ -444,7 +444,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',
@@ -474,6 +474,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.weekendDays': 'Jours de week-end',
@@ -499,11 +507,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',
@@ -589,14 +597,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',
@@ -614,7 +622,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é',
@@ -642,7 +650,7 @@ const fr: Record = {
'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',
@@ -652,7 +660,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)',
@@ -666,10 +674,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.',
@@ -714,7 +722,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',
@@ -746,14 +754,14 @@ const fr: Record = {
'reservations.confirm.deleteBody': '« {name} » sera définitivement supprimé.',
'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',
@@ -761,22 +769,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',
@@ -794,7 +802,7 @@ 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',
@@ -809,12 +817,12 @@ const fr: Record = {
'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',
@@ -822,7 +830,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é',
@@ -841,18 +849,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',
@@ -873,8 +881,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',
@@ -988,10 +996,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',
@@ -999,14 +1007,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',
@@ -1032,15 +1040,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',
@@ -1077,8 +1085,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…',
@@ -1108,7 +1116,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}',
@@ -1184,7 +1192,7 @@ const fr: Record = {
'memories.confirmShareButton': 'Partager les photos',
// Collab Addon
- 'collab.tabs.chat': 'Chat',
+ 'collab.tabs.chat': 'Discussion',
'collab.tabs.notes': 'Notes',
'collab.tabs.polls': 'Sondages',
'collab.whatsNext.title': 'À venir',
@@ -1194,7 +1202,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',
@@ -1211,9 +1219,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',
@@ -1227,7 +1235,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',
diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx
index b729a61..8266ea6 100644
--- a/client/src/pages/AdminPage.tsx
+++ b/client/src/pages/AdminPage.tsx
@@ -52,7 +52,7 @@ interface UpdateInfo {
}
export default function AdminPage(): React.ReactElement {
- const { demoMode } = useAuthStore()
+ const { demoMode, serverTimezone } = useAuthStore()
const { t, locale } = useTranslation()
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
const TABS = [
@@ -512,10 +512,10 @@ export default function AdminPage(): React.ReactElement {
- {new Date(u.created_at).toLocaleDateString(locale)}
+ {new Date(u.created_at).toLocaleDateString(locale, { timeZone: serverTimezone })}
|
- {u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'}
+ {u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12, timeZone: serverTimezone }) : '—'}
|
@@ -584,7 +584,7 @@ export default function AdminPage(): React.ReactElement {
{inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')}
- {inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale)}`}
+ {inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale, { timeZone: serverTimezone })}`}
{` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts
index 1ce5211..8387c4e 100644
--- a/client/src/store/authStore.ts
+++ b/client/src/store/authStore.ts
@@ -23,6 +23,7 @@ interface AuthState {
error: string | null
demoMode: boolean
hasMapsKey: boolean
+ serverTimezone: string
login: (email: string, password: string) => Promise
completeMfaLogin: (mfaToken: string, code: string) => Promise
@@ -36,6 +37,7 @@ interface AuthState {
deleteAvatar: () => Promise
setDemoMode: (val: boolean) => void
setHasMapsKey: (val: boolean) => void
+ setServerTimezone: (tz: string) => void
demoLogin: () => Promise
}
@@ -47,6 +49,7 @@ export const useAuthStore = create((set, get) => ({
error: null,
demoMode: localStorage.getItem('demo_mode') === 'true',
hasMapsKey: false,
+ serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
login: async (email: string, password: string) => {
set({ isLoading: true, error: null })
@@ -201,6 +204,7 @@ export const useAuthStore = create((set, get) => ({
},
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
+ setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
demoLogin: async () => {
set({ isLoading: true, error: null })
diff --git a/client/src/types.ts b/client/src/types.ts
index f0da1ee..ac232ee 100644
--- a/client/src/types.ts
+++ b/client/src/types.ts
@@ -280,6 +280,7 @@ export interface AppConfig {
oidc_display_name?: string
has_maps_key?: boolean
allowed_file_types?: string
+ timezone?: string
}
// Translation function type
diff --git a/client/src/utils/formatters.ts b/client/src/utils/formatters.ts
index 4fd3409..7e7ebc0 100644
--- a/client/src/utils/formatters.ts
+++ b/client/src/utils/formatters.ts
@@ -6,11 +6,13 @@ export function currencyDecimals(currency: string): number {
return ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 0 : 2
}
-export function formatDate(dateStr: string | null | undefined, locale: string): string | null {
+export function formatDate(dateStr: string | null | undefined, locale: string, timeZone?: string): string | null {
if (!dateStr) return null
- return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, {
+ const opts: Intl.DateTimeFormatOptions = {
weekday: 'short', day: 'numeric', month: 'short',
- })
+ }
+ if (timeZone) opts.timeZone = timeZone
+ return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, opts)
}
export function formatTime(timeStr: string | null | undefined, locale: string, timeFormat: string): string {
diff --git a/docker-compose.yml b/docker-compose.yml
index 1acc607..91a97e9 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -9,6 +9,7 @@ services:
- JWT_SECRET=${JWT_SECRET:-}
# - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins
- PORT=3000
+ - TZ=${TZ:-UTC}
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts
index c7aaf2a..eea850f 100644
--- a/server/src/routes/admin.ts
+++ b/server/src/routes/admin.ts
@@ -12,6 +12,11 @@ const router = express.Router();
router.use(authenticate, adminOnly);
+function utcSuffix(ts: string | null | undefined): string | null {
+ if (!ts) return null;
+ return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
+}
+
router.get('/users', (req: Request, res: Response) => {
const users = db.prepare(
'SELECT id, username, email, role, created_at, updated_at, last_login FROM users ORDER BY created_at DESC'
@@ -21,7 +26,13 @@ router.get('/users', (req: Request, res: Response) => {
const { getOnlineUserIds } = require('../websocket');
onlineUserIds = getOnlineUserIds();
} catch { /* */ }
- const usersWithStatus = users.map(u => ({ ...u, online: onlineUserIds.has(u.id) }));
+ const usersWithStatus = users.map(u => ({
+ ...u,
+ created_at: utcSuffix(u.created_at),
+ updated_at: utcSuffix(u.updated_at as string),
+ last_login: utcSuffix(u.last_login),
+ online: onlineUserIds.has(u.id),
+ }));
res.json({ users: usersWithStatus });
});
diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts
index b5518ef..de58aeb 100644
--- a/server/src/routes/auth.ts
+++ b/server/src/routes/auth.ts
@@ -28,6 +28,11 @@ function getPendingMfaSecret(userId: number): string | null {
return row.secret;
}
+function utcSuffix(ts: string | null | undefined): string | null {
+ if (!ts) return null;
+ return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
+}
+
function stripUserForClient(user: User): Record {
const {
password_hash: _p,
@@ -39,6 +44,9 @@ function stripUserForClient(user: User): Record {
} = user;
return {
...rest,
+ created_at: utcSuffix(rest.created_at),
+ updated_at: utcSuffix(rest.updated_at),
+ last_login: utcSuffix(rest.last_login),
mfa_enabled: !!(user.mfa_enabled === 1 || user.mfa_enabled === true),
};
}
@@ -146,6 +154,7 @@ router.get('/app-config', (_req: Request, res: Response) => {
demo_mode: isDemo,
demo_email: isDemo ? 'demo@trek.app' : undefined,
demo_password: isDemo ? 'demo12345' : undefined,
+ timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
});
});
diff --git a/server/src/routes/backup.ts b/server/src/routes/backup.ts
index e18190d..832b355 100644
--- a/server/src/routes/backup.ts
+++ b/server/src/routes/backup.ts
@@ -212,17 +212,30 @@ router.post('/upload-restore', uploadTmp.single('backup'), async (req: Request,
router.get('/auto-settings', (_req: Request, res: Response) => {
try {
- res.json({ settings: scheduler.loadSettings() });
+ const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
+ res.json({ settings: scheduler.loadSettings(), timezone: tz });
} catch (err: unknown) {
console.error('[backup] GET auto-settings:', err);
res.status(500).json({ error: 'Could not load backup settings' });
}
});
+function parseIntField(raw: unknown, fallback: number): number {
+ if (typeof raw === 'number' && Number.isFinite(raw)) return Math.floor(raw);
+ if (typeof raw === 'string' && raw.trim() !== '') {
+ const n = parseInt(raw, 10);
+ if (Number.isFinite(n)) return n;
+ }
+ return fallback;
+}
+
function parseAutoBackupBody(body: Record): {
enabled: boolean;
interval: string;
keep_days: number;
+ hour: number;
+ day_of_week: number;
+ day_of_month: number;
} {
const enabled = body.enabled === true || body.enabled === 'true' || body.enabled === 1;
const rawInterval = body.interval;
@@ -230,17 +243,11 @@ function parseAutoBackupBody(body: Record): {
typeof rawInterval === 'string' && scheduler.VALID_INTERVALS.includes(rawInterval)
? rawInterval
: 'daily';
- const rawKeep = body.keep_days;
- let keepNum: number;
- if (typeof rawKeep === 'number' && Number.isFinite(rawKeep)) {
- keepNum = Math.floor(rawKeep);
- } else if (typeof rawKeep === 'string' && rawKeep.trim() !== '') {
- keepNum = parseInt(rawKeep, 10);
- } else {
- keepNum = NaN;
- }
- const keep_days = Number.isFinite(keepNum) && keepNum >= 0 ? keepNum : 7;
- return { enabled, interval, keep_days };
+ const keep_days = Math.max(0, parseIntField(body.keep_days, 7));
+ const hour = Math.min(23, Math.max(0, parseIntField(body.hour, 2)));
+ const day_of_week = Math.min(6, Math.max(0, parseIntField(body.day_of_week, 0)));
+ const day_of_month = Math.min(28, Math.max(1, parseIntField(body.day_of_month, 1)));
+ return { enabled, interval, keep_days, hour, day_of_week, day_of_month };
}
router.put('/auto-settings', (req: Request, res: Response) => {
diff --git a/server/src/scheduler.ts b/server/src/scheduler.ts
index 2a8a75d..a3272c2 100644
--- a/server/src/scheduler.ts
+++ b/server/src/scheduler.ts
@@ -8,30 +8,48 @@ const backupsDir = path.join(dataDir, 'backups');
const uploadsDir = path.join(__dirname, '../uploads');
const settingsFile = path.join(dataDir, 'backup-settings.json');
-const CRON_EXPRESSIONS: Record = {
- hourly: '0 * * * *',
- daily: '0 2 * * *',
- weekly: '0 2 * * 0',
- monthly: '0 2 1 * *',
-};
-
-const VALID_INTERVALS = Object.keys(CRON_EXPRESSIONS);
+const VALID_INTERVALS = ['hourly', 'daily', 'weekly', 'monthly'];
+const VALID_DAYS_OF_WEEK = [0, 1, 2, 3, 4, 5, 6]; // 0=Sunday
+const VALID_HOURS = Array.from({ length: 24 }, (_, i) => i);
interface BackupSettings {
enabled: boolean;
interval: string;
keep_days: number;
+ hour: number;
+ day_of_week: number;
+ day_of_month: number;
+}
+
+function buildCronExpression(settings: BackupSettings): string {
+ const hour = VALID_HOURS.includes(settings.hour) ? settings.hour : 2;
+ const dow = VALID_DAYS_OF_WEEK.includes(settings.day_of_week) ? settings.day_of_week : 0;
+ const dom = settings.day_of_month >= 1 && settings.day_of_month <= 28 ? settings.day_of_month : 1;
+
+ switch (settings.interval) {
+ case 'hourly': return '0 * * * *';
+ case 'daily': return `0 ${hour} * * *`;
+ case 'weekly': return `0 ${hour} * * ${dow}`;
+ case 'monthly': return `0 ${hour} ${dom} * *`;
+ default: return `0 ${hour} * * *`;
+ }
}
let currentTask: ScheduledTask | null = null;
+function getDefaults(): BackupSettings {
+ return { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 };
+}
+
function loadSettings(): BackupSettings {
+ let settings = getDefaults();
try {
if (fs.existsSync(settingsFile)) {
- return JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
+ const saved = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
+ settings = { ...settings, ...saved };
}
} catch (e) {}
- return { enabled: false, interval: 'daily', keep_days: 7 };
+ return settings;
}
function saveSettings(settings: BackupSettings): void {
@@ -104,9 +122,10 @@ function start(): void {
return;
}
- const expression = CRON_EXPRESSIONS[settings.interval] || CRON_EXPRESSIONS.daily;
- currentTask = cron.schedule(expression, runBackup);
- console.log(`[Auto-Backup] Scheduled: ${settings.interval} (${expression}), retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`);
+ const expression = buildCronExpression(settings);
+ const tz = process.env.TZ || 'UTC';
+ currentTask = cron.schedule(expression, runBackup, { timezone: tz });
+ console.log(`[Auto-Backup] Scheduled: ${settings.interval} (${expression}), tz: ${tz}, retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`);
}
// Demo mode: hourly reset of demo user data
|