diff --git a/README.md b/README.md
index c5954f1..2c6e28b 100644
--- a/README.md
+++ b/README.md
@@ -72,7 +72,7 @@
### Customization & Admin
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching
-- **Multilingual** — English and German (i18n)
+- **Multilingual** — English, German, Chinese (Simplified), Dutch, Russian (i18n)
- **Admin Panel** — User management, global categories, addon management, API keys, backups, and GitHub release history
- **Auto-Backups** — Scheduled backups with configurable interval and retention
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
diff --git a/client/src/components/Dashboard/TimezoneWidget.tsx b/client/src/components/Dashboard/TimezoneWidget.tsx
index 5ee97e5..087937a 100644
--- a/client/src/components/Dashboard/TimezoneWidget.tsx
+++ b/client/src/components/Dashboard/TimezoneWidget.tsx
@@ -51,6 +51,9 @@ export default function TimezoneWidget() {
})
const [now, setNow] = useState(Date.now())
const [showAdd, setShowAdd] = useState(false)
+ const [customLabel, setCustomLabel] = useState('')
+ const [customTz, setCustomTz] = useState('')
+ const [customError, setCustomError] = useState('')
useEffect(() => {
const i = setInterval(() => setNow(Date.now()), 10000)
@@ -61,6 +64,20 @@ export default function TimezoneWidget() {
localStorage.setItem('dashboard_timezones', JSON.stringify(zones))
}, [zones])
+ const isValidTz = (tz: string) => {
+ try { Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date()); return true } catch { return false }
+ }
+
+ const addCustomZone = () => {
+ const tz = customTz.trim()
+ if (!tz) { setCustomError(t('dashboard.timezoneCustomErrorEmpty')); return }
+ if (!isValidTz(tz)) { setCustomError(t('dashboard.timezoneCustomErrorInvalid')); return }
+ if (zones.find(z => z.tz === tz)) { setCustomError(t('dashboard.timezoneCustomErrorDuplicate')); return }
+ const label = customLabel.trim() || tz.split('/').pop()?.replace(/_/g, ' ') || tz
+ setZones([...zones, { label, tz }])
+ setCustomLabel(''); setCustomTz(''); setCustomError(''); setShowAdd(false)
+ }
+
const addZone = (zone) => {
if (!zones.find(z => z.tz === zone.tz)) {
setZones([...zones, zone])
@@ -108,7 +125,29 @@ export default function TimezoneWidget() {
{/* Add zone dropdown */}
{showAdd && (
-
+
+ {/* Custom timezone */}
+
+
{t('dashboard.timezoneCustomTitle')}
+
+
setCustomLabel(e.target.value)}
+ placeholder={t('dashboard.timezoneCustomLabelPlaceholder')}
+ className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
+ style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border-secondary)' }} />
+
{ setCustomTz(e.target.value); setCustomError('') }}
+ placeholder={t('dashboard.timezoneCustomTzPlaceholder')}
+ className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
+ style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: `1px solid ${customError ? '#ef4444' : 'var(--border-secondary)'}` }}
+ onKeyDown={e => { if (e.key === 'Enter') addCustomZone() }} />
+ {customError &&
{customError}
}
+
+
+
+ {/* Popular zones */}
{POPULAR_ZONES.filter(z => !zones.find(existing => existing.tz === z.tz)).map(z => (
))}
{assignment.participants.length > 5 && (
diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx
index 6f48961..ef4ec5f 100644
--- a/client/src/components/Planner/PlaceFormModal.tsx
+++ b/client/src/components/Planner/PlaceFormModal.tsx
@@ -281,6 +281,15 @@ export default function PlaceFormModal({
step="any"
value={form.lat}
onChange={e => handleChange('lat', e.target.value)}
+ onPaste={e => {
+ const text = e.clipboardData.getData('text').trim()
+ const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/)
+ if (match) {
+ e.preventDefault()
+ handleChange('lat', match[1])
+ handleChange('lng', match[2])
+ }
+ }}
placeholder={t('places.formLat')}
className="form-input"
/>
diff --git a/client/src/components/Vacay/VacayMonthCard.tsx b/client/src/components/Vacay/VacayMonthCard.tsx
index a012d0d..d47de05 100644
--- a/client/src/components/Vacay/VacayMonthCard.tsx
+++ b/client/src/components/Vacay/VacayMonthCard.tsx
@@ -10,6 +10,13 @@ const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July
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']
+function hexToRgba(hex: string, alpha: number): string {
+ const r = parseInt(hex.slice(1, 3), 16)
+ const g = parseInt(hex.slice(3, 5), 16)
+ const b = parseInt(hex.slice(5, 7), 16)
+ return `rgba(${r},${g},${b},${alpha})`
+}
+
interface VacayMonthCardProps {
year: number
month: number
@@ -88,7 +95,7 @@ export default function VacayMonthCard({
onMouseEnter={e => { if (!isBlocked) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = weekend ? 'var(--bg-secondary)' : 'transparent' }}
>
- {holiday &&
}
+ {holiday &&
}
{isCompany &&
}
{dayEntries.length === 1 && (
@@ -117,7 +124,7 @@ export default function VacayMonthCard({
)}
0 ? 700 : 500,
}}>
{day}
diff --git a/client/src/components/Vacay/VacaySettings.tsx b/client/src/components/Vacay/VacaySettings.tsx
index 626f29e..8513ada 100644
--- a/client/src/components/Vacay/VacaySettings.tsx
+++ b/client/src/components/Vacay/VacaySettings.tsx
@@ -1,10 +1,11 @@
import { useState, useEffect } from 'react'
-import { MapPin, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe } from 'lucide-react'
+import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2 } from 'lucide-react'
import { useVacayStore } from '../../store/vacayStore'
import { getIntlLanguage, useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import apiClient from '../../api/client'
+import type { VacayHolidayCalendar } from '../../types'
interface VacaySettingsProps {
onClose: () => void
@@ -13,10 +14,9 @@ interface VacaySettingsProps {
export default function VacaySettings({ onClose }: VacaySettingsProps) {
const { t } = useTranslation()
const toast = useToast()
- const { plan, updatePlan, isFused, dissolve, users } = useVacayStore()
- const [countries, setCountries] = useState([])
- const [regions, setRegions] = useState([])
- const [loadingRegions, setLoadingRegions] = useState(false)
+ const { plan, updatePlan, addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar, isFused, dissolve, users } = useVacayStore()
+ const [countries, setCountries] = useState<{ value: string; label: string }[]>([])
+ const [showAddForm, setShowAddForm] = useState(false)
const { language } = useTranslation()
@@ -34,57 +34,9 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
}).catch(() => {})
}, [language])
- // When country changes, check if it has regions
- const selectedCountry = plan?.holidays_region?.split('-')[0] || ''
- const selectedRegion = plan?.holidays_region?.includes('-') ? plan.holidays_region : ''
-
- useEffect(() => {
- if (!selectedCountry || !plan?.holidays_enabled) { setRegions([]); return }
- setLoadingRegions(true)
- const year = new Date().getFullYear()
- apiClient.get(`/addons/vacay/holidays/${year}/${selectedCountry}`).then(r => {
- const allCounties = new Set()
- r.data.forEach(h => {
- if (h.counties) h.counties.forEach(c => allCounties.add(c))
- })
- if (allCounties.size > 0) {
- let subdivisionNames
- try { subdivisionNames = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' }) } catch { /* */ }
- const regionList = [...allCounties].sort().map(c => {
- let label = c.split('-')[1] || c
- // Try Intl for full subdivision name (not all browsers support subdivision codes)
- // Fallback: use known mappings for DE
- if (c.startsWith('DE-')) {
- const deRegions = { BW:'Baden-Württemberg',BY:'Bayern',BE:'Berlin',BB:'Brandenburg',HB:'Bremen',HH:'Hamburg',HE:'Hessen',MV:'Mecklenburg-Vorpommern',NI:'Niedersachsen',NW:'Nordrhein-Westfalen',RP:'Rheinland-Pfalz',SL:'Saarland',SN:'Sachsen',ST:'Sachsen-Anhalt',SH:'Schleswig-Holstein',TH:'Thüringen' }
- label = deRegions[c.split('-')[1]] || label
- } else if (c.startsWith('CH-')) {
- const chRegions = { AG:'Aargau',AI:'Appenzell Innerrhoden',AR:'Appenzell Ausserrhoden',BE:'Bern',BL:'Basel-Landschaft',BS:'Basel-Stadt',FR:'Freiburg',GE:'Genf',GL:'Glarus',GR:'Graubünden',JU:'Jura',LU:'Luzern',NE:'Neuenburg',NW:'Nidwalden',OW:'Obwalden',SG:'St. Gallen',SH:'Schaffhausen',SO:'Solothurn',SZ:'Schwyz',TG:'Thurgau',TI:'Tessin',UR:'Uri',VD:'Waadt',VS:'Wallis',ZG:'Zug',ZH:'Zürich' }
- label = chRegions[c.split('-')[1]] || label
- }
- return { value: c, label }
- })
- setRegions(regionList)
- } else {
- setRegions([])
- // If no regions, just set country code as region
- if (plan.holidays_region !== selectedCountry) {
- updatePlan({ holidays_region: selectedCountry })
- }
- }
- }).catch(() => setRegions([])).finally(() => setLoadingRegions(false))
- }, [selectedCountry, plan?.holidays_enabled])
-
if (!plan) return null
- const toggle = (key) => updatePlan({ [key]: !plan[key] })
-
- const handleCountryChange = (countryCode) => {
- updatePlan({ holidays_region: countryCode })
- }
-
- const handleRegionChange = (regionCode) => {
- updatePlan({ holidays_region: regionCode })
- }
+ const toggle = (key: string) => updatePlan({ [key]: !plan[key] })
return (
@@ -136,21 +88,35 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
/>
{plan.holidays_enabled && (
-
- {regions.length > 0 && (
-
{t('vacay.noCalendars')}
+ )}
+ {(plan.holiday_calendars ?? []).map(cal => (
+ updateHolidayCalendar(cal.id, data)}
+ onDelete={() => deleteHolidayCalendar(cal.id)}
/>
+ ))}
+ {showAddForm ? (
+ { await addHolidayCalendar(data); setShowAddForm(false) }}
+ onCancel={() => setShowAddForm(false)}
+ />
+ ) : (
+
)}
)}
@@ -197,11 +163,11 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
}
interface SettingToggleProps {
- icon: React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }>
+ icon: LucideIcon
label: string
hint: string
value: boolean
- onChange: (value: boolean) => void
+ onChange: () => void
}
function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingToggleProps) {
@@ -223,3 +189,202 @@ function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingTogg
)
}
+
+// ── shared region-loading helper ─────────────────────────────────────────────
+async function fetchRegionOptions(country: string): Promise<{ value: string; label: string }[]> {
+ try {
+ const year = new Date().getFullYear()
+ const r = await apiClient.get(`/addons/vacay/holidays/${year}/${country}`)
+ const allCounties = new Set()
+ r.data.forEach(h => { if (h.counties) h.counties.forEach(c => allCounties.add(c)) })
+ if (allCounties.size === 0) return []
+ return [...allCounties].sort().map(c => {
+ let label = c.split('-')[1] || c
+ if (c.startsWith('DE-')) {
+ const m: Record = { BW:'Baden-Württemberg',BY:'Bayern',BE:'Berlin',BB:'Brandenburg',HB:'Bremen',HH:'Hamburg',HE:'Hessen',MV:'Mecklenburg-Vorpommern',NI:'Niedersachsen',NW:'Nordrhein-Westfalen',RP:'Rheinland-Pfalz',SL:'Saarland',SN:'Sachsen',ST:'Sachsen-Anhalt',SH:'Schleswig-Holstein',TH:'Thüringen' }
+ label = m[c.split('-')[1]] || label
+ } else if (c.startsWith('CH-')) {
+ const m: Record = { AG:'Aargau',AI:'Appenzell Innerrhoden',AR:'Appenzell Ausserrhoden',BE:'Bern',BL:'Basel-Landschaft',BS:'Basel-Stadt',FR:'Freiburg',GE:'Genf',GL:'Glarus',GR:'Graubünden',JU:'Jura',LU:'Luzern',NE:'Neuenburg',NW:'Nidwalden',OW:'Obwalden',SG:'St. Gallen',SH:'Schaffhausen',SO:'Solothurn',SZ:'Schwyz',TG:'Thurgau',TI:'Tessin',UR:'Uri',VD:'Waadt',VS:'Wallis',ZG:'Zug',ZH:'Zürich' }
+ label = m[c.split('-')[1]] || label
+ }
+ return { value: c, label }
+ })
+ } catch {
+ return []
+ }
+}
+
+// ── Existing calendar row (inline edit) ──────────────────────────────────────
+function CalendarRow({ cal, countries, onUpdate, onDelete }: {
+ cal: VacayHolidayCalendar
+ countries: { value: string; label: string }[]
+ language: string
+ onUpdate: (data: { region?: string; color?: string; label?: string | null }) => void
+ onDelete: () => void
+}) {
+ const { t } = useTranslation()
+ const [localColor, setLocalColor] = useState(cal.color)
+ const [localLabel, setLocalLabel] = useState(cal.label || '')
+ const [regions, setRegions] = useState<{ value: string; label: string }[]>([])
+
+ const selectedCountry = cal.region.split('-')[0]
+ const selectedRegion = cal.region.includes('-') ? cal.region : ''
+
+ useEffect(() => { setLocalColor(cal.color) }, [cal.color])
+ useEffect(() => { setLocalLabel(cal.label || '') }, [cal.label])
+
+ useEffect(() => {
+ if (!selectedCountry) { setRegions([]); return }
+ fetchRegionOptions(selectedCountry).then(setRegions)
+ }, [selectedCountry])
+
+ const PRESET_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af', '#6366f1', '#ef4444', '#22c55e', '#3b82f6']
+ const [showColorPicker, setShowColorPicker] = useState(false)
+
+ return (
+
+
+
+
+ setLocalLabel(e.target.value)}
+ onBlur={() => { const v = localLabel.trim() || null; if (v !== cal.label) onUpdate({ label: v }) }}
+ onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
+ placeholder={t('vacay.calendarLabel')}
+ style={{ width: '100%', fontSize: 12, padding: '6px 10px', borderRadius: 8, background: 'var(--bg-input)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
+ />
+ onUpdate({ region: v })}
+ options={countries}
+ placeholder={t('vacay.selectCountry')}
+ searchable
+ />
+ {regions.length > 0 && (
+ onUpdate({ region: v })}
+ options={regions}
+ placeholder={t('vacay.selectRegion')}
+ searchable
+ />
+ )}
+
+
{ (e.currentTarget as HTMLButtonElement).style.background = 'rgba(239,68,68,0.1)' }}
+ onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
+ >
+
+
+
+ )
+}
+
+// ── Add-new-calendar form ─────────────────────────────────────────────────────
+function AddCalendarForm({ countries, onAdd, onCancel }: {
+ countries: { value: string; label: string }[]
+ language: string
+ onAdd: (data: { region: string; color: string; label: string | null }) => void
+ onCancel: () => void
+}) {
+ const { t } = useTranslation()
+ const [region, setRegion] = useState('')
+ const [color, setColor] = useState('#fecaca')
+ const [label, setLabel] = useState('')
+ const [regions, setRegions] = useState<{ value: string; label: string }[]>([])
+ const [loadingRegions, setLoadingRegions] = useState(false)
+
+ const selectedCountry = region.split('-')[0] || ''
+ const selectedRegion = region.includes('-') ? region : ''
+
+ useEffect(() => {
+ if (!selectedCountry) { setRegions([]); return }
+ setLoadingRegions(true)
+ fetchRegionOptions(selectedCountry).then(list => { setRegions(list) }).finally(() => setLoadingRegions(false))
+ }, [selectedCountry])
+
+ const canAdd = selectedCountry && (regions.length === 0 || selectedRegion !== '')
+
+ const PRESET_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af', '#6366f1', '#ef4444', '#22c55e', '#3b82f6']
+ const [showColorPicker, setShowColorPicker] = useState(false)
+
+ return (
+
+
+
setShowColorPicker(!showColorPicker)}
+ style={{ width: 28, height: 28, borderRadius: 8, background: color, border: '2px solid var(--border-primary)', cursor: 'pointer' }}
+ title={t('vacay.calendarColor')}
+ />
+ {showColorPicker && (
+
+ {PRESET_COLORS.map(c => (
+ { setColor(c); setShowColorPicker(false) }}
+ style={{ width: 24, height: 24, borderRadius: 6, background: c, border: color === c ? '2px solid var(--text-primary)' : '2px solid transparent', cursor: 'pointer' }} />
+ ))}
+
+ )}
+
+
+
setLabel(e.target.value)}
+ placeholder={t('vacay.calendarLabel')}
+ style={{ width: '100%', fontSize: 12, padding: '6px 10px', borderRadius: 8, background: 'var(--bg-input)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
+ />
+
{ setRegion(v); setRegions([]) }}
+ options={countries}
+ placeholder={t('vacay.selectCountry')}
+ searchable
+ />
+ {regions.length > 0 && (
+ setRegion(v)}
+ options={regions}
+ placeholder={t('vacay.selectRegion')}
+ searchable
+ />
+ )}
+
+ onAdd({ region: region || selectedCountry, color, label: label.trim() || null })}
+ className="flex-1 text-xs px-2 py-1.5 rounded-md font-medium transition-colors disabled:opacity-40"
+ style={{ background: 'var(--text-primary)', color: 'var(--bg-card)' }}
+ >
+ {t('vacay.add')}
+
+
+ ✕
+
+
+
+
+ )
+}
diff --git a/client/src/i18n/TranslationContext.tsx b/client/src/i18n/TranslationContext.tsx
index 408bdf7..51e5c4f 100644
--- a/client/src/i18n/TranslationContext.tsx
+++ b/client/src/i18n/TranslationContext.tsx
@@ -3,18 +3,22 @@ import { useSettingsStore } from '../store/settingsStore'
import de from './translations/de'
import en from './translations/en'
import es from './translations/es'
+import fr from './translations/fr'
+import ru from './translations/ru'
+import zh from './translations/zh'
+import nl from './translations/nl'
-type TranslationStrings = Record
+type TranslationStrings = Record
-const translations: Record = { de, en, es }
-const LOCALES: Record = { de: 'de-DE', en: 'en-US', es: 'es-ES' }
+const translations: Record = { de, en, es, fr, ru, zh, nl }
+const LOCALES: Record = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', ru: 'ru-RU', zh: 'zh-CN', nl: 'nl-NL' }
export function getLocaleForLanguage(language: string): string {
return LOCALES[language] || LOCALES.en
}
export function getIntlLanguage(language: string): string {
- return language === 'de' || language === 'es' ? language : 'en'
+ return ['de', 'es', 'fr', 'ru', 'zh', 'nl'].includes(language) ? language : 'en'
}
interface TranslationContextValue {
@@ -37,7 +41,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
const fallback = translations.en
function t(key: string, params?: Record): string {
- let val: string = strings[key] ?? fallback[key] ?? key
+ let val: string = (strings[key] ?? fallback[key] ?? key) as string
if (params) {
Object.entries(params).forEach(([k, v]) => {
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts
index d18384c..84bafe7 100644
--- a/client/src/i18n/translations/de.ts
+++ b/client/src/i18n/translations/de.ts
@@ -1,4 +1,4 @@
-const de: Record = {
+const de: Record = {
// Allgemein
'common.save': 'Speichern',
'common.cancel': 'Abbrechen',
@@ -54,6 +54,13 @@ const de: Record = {
'dashboard.currency': 'Währung',
'dashboard.timezone': 'Zeitzonen',
'dashboard.localTime': 'Lokal',
+ 'dashboard.timezoneCustomTitle': 'Eigene Zeitzone',
+ 'dashboard.timezoneCustomLabelPlaceholder': 'Bezeichnung (optional)',
+ 'dashboard.timezoneCustomTzPlaceholder': 'z.B. America/New_York',
+ 'dashboard.timezoneCustomAdd': 'Hinzufügen',
+ 'dashboard.timezoneCustomErrorEmpty': 'Zeitzone eingeben',
+ 'dashboard.timezoneCustomErrorInvalid': 'Ungültige Zeitzone. Format: Europe/Berlin',
+ 'dashboard.timezoneCustomErrorDuplicate': 'Bereits hinzugefügt',
'dashboard.emptyTitle': 'Noch keine Reisen',
'dashboard.emptyText': 'Erstelle deine erste Reise und beginne mit der Planung von Orten, Tagesabläufen und Packlisten.',
'dashboard.emptyButton': 'Erste Reise erstellen',
@@ -322,6 +329,20 @@ const de: Record = {
'admin.tabs.addons': 'Addons',
'admin.addons.title': 'Addons',
'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um TREK nach deinen Wünschen anzupassen.',
+ 'admin.addons.catalog.memories.name': 'Erinnerungen',
+ 'admin.addons.catalog.memories.description': 'Geteilte Fotoalben für jede Reise',
+ 'admin.addons.catalog.packing.name': 'Packliste',
+ 'admin.addons.catalog.packing.description': 'Checklisten zum Kofferpacken für jede Reise',
+ 'admin.addons.catalog.budget.name': 'Budget',
+ 'admin.addons.catalog.budget.description': 'Ausgaben verfolgen und Reisebudget planen',
+ 'admin.addons.catalog.documents.name': 'Dokumente',
+ 'admin.addons.catalog.documents.description': 'Reisedokumente speichern und verwalten',
+ 'admin.addons.catalog.vacay.name': 'Vacay',
+ 'admin.addons.catalog.vacay.description': 'Persönlicher Urlaubsplaner mit Kalenderansicht',
+ 'admin.addons.catalog.atlas.name': 'Atlas',
+ 'admin.addons.catalog.atlas.description': 'Weltkarte mit besuchten Ländern und Reisestatistiken',
+ 'admin.addons.catalog.collab.name': 'Collab',
+ 'admin.addons.catalog.collab.description': 'Echtzeit-Notizen, Umfragen und Chat für die Reiseplanung',
'admin.addons.subtitleBefore': 'Aktiviere oder deaktiviere Funktionen, um ',
'admin.addons.subtitleAfter': ' nach deinen Wünschen anzupassen.',
'admin.addons.enabled': 'Aktiviert',
@@ -413,6 +434,10 @@ const de: Record = {
'vacay.publicHolidaysHint': 'Feiertage im Kalender markieren',
'vacay.selectCountry': 'Land wählen',
'vacay.selectRegion': 'Region wählen (optional)',
+ 'vacay.addCalendar': 'Kalender hinzufügen',
+ 'vacay.calendarLabel': 'Bezeichnung (optional)',
+ 'vacay.calendarColor': 'Farbe',
+ 'vacay.noCalendars': 'Noch keine Feiertagskalender angelegt',
'vacay.companyHolidays': 'Betriebsferien',
'vacay.companyHolidaysHint': 'Erlaubt das Markieren von unternehmensweiten Feiertagen',
'vacay.companyHolidaysNoDeduct': 'Betriebsferien werden nicht vom Urlaubskontingent abgezogen.',
diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts
index 1778181..03254ba 100644
--- a/client/src/i18n/translations/en.ts
+++ b/client/src/i18n/translations/en.ts
@@ -1,4 +1,4 @@
-const en: Record = {
+const en: Record = {
// Common
'common.save': 'Save',
'common.cancel': 'Cancel',
@@ -54,6 +54,13 @@ const en: Record = {
'dashboard.currency': 'Currency',
'dashboard.timezone': 'Timezones',
'dashboard.localTime': 'Local',
+ 'dashboard.timezoneCustomTitle': 'Custom Timezone',
+ 'dashboard.timezoneCustomLabelPlaceholder': 'Label (optional)',
+ 'dashboard.timezoneCustomTzPlaceholder': 'e.g. America/New_York',
+ 'dashboard.timezoneCustomAdd': 'Add',
+ 'dashboard.timezoneCustomErrorEmpty': 'Enter a timezone identifier',
+ 'dashboard.timezoneCustomErrorInvalid': 'Invalid timezone. Use format like Europe/Berlin',
+ 'dashboard.timezoneCustomErrorDuplicate': 'Already added',
'dashboard.emptyTitle': 'No trips yet',
'dashboard.emptyText': 'Create your first trip and start planning!',
'dashboard.emptyButton': 'Create First Trip',
@@ -322,6 +329,20 @@ const en: Record = {
'admin.tabs.addons': 'Addons',
'admin.addons.title': 'Addons',
'admin.addons.subtitle': 'Enable or disable features to customize your TREK experience.',
+ 'admin.addons.catalog.memories.name': 'Memories',
+ 'admin.addons.catalog.memories.description': 'Shared photo albums for each trip',
+ 'admin.addons.catalog.packing.name': 'Packing',
+ 'admin.addons.catalog.packing.description': 'Checklists to prepare your luggage for each trip',
+ 'admin.addons.catalog.budget.name': 'Budget',
+ 'admin.addons.catalog.budget.description': 'Track expenses and plan your trip budget',
+ 'admin.addons.catalog.documents.name': 'Documents',
+ 'admin.addons.catalog.documents.description': 'Store and manage travel documents',
+ 'admin.addons.catalog.vacay.name': 'Vacay',
+ 'admin.addons.catalog.vacay.description': 'Personal vacation planner with calendar view',
+ 'admin.addons.catalog.atlas.name': 'Atlas',
+ 'admin.addons.catalog.atlas.description': 'World map with visited countries and travel stats',
+ 'admin.addons.catalog.collab.name': 'Collab',
+ 'admin.addons.catalog.collab.description': 'Real-time notes, polls, and chat for trip planning',
'admin.addons.subtitleBefore': 'Enable or disable features to customize your ',
'admin.addons.subtitleAfter': ' experience.',
'admin.addons.enabled': 'Enabled',
@@ -413,6 +434,10 @@ const en: Record = {
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
'vacay.selectCountry': 'Select country',
'vacay.selectRegion': 'Select region (optional)',
+ 'vacay.addCalendar': 'Add calendar',
+ 'vacay.calendarLabel': 'Label (optional)',
+ 'vacay.calendarColor': 'Color',
+ 'vacay.noCalendars': 'No holiday calendars added yet',
'vacay.companyHolidays': 'Company Holidays',
'vacay.companyHolidaysHint': 'Allow marking company-wide holiday days',
'vacay.companyHolidaysNoDeduct': 'Company holidays do not count towards vacation days.',
diff --git a/client/src/i18n/translations/es.js b/client/src/i18n/translations/es.js
deleted file mode 100644
index 2146dfb..0000000
--- a/client/src/i18n/translations/es.js
+++ /dev/null
@@ -1,1061 +0,0 @@
-const es = {
- // Common
- 'common.save': 'Guardar',
- 'common.cancel': 'Cancelar',
- 'common.delete': 'Eliminar',
- 'common.edit': 'Editar',
- 'common.add': 'Añadir',
- 'common.loading': 'Cargando...',
- 'common.error': 'Error',
- 'common.back': 'Atrás',
- 'common.all': 'Todo',
- 'common.close': 'Cerrar',
- 'common.open': 'Abrir',
- 'common.upload': 'Subir',
- 'common.search': 'Buscar',
- 'common.confirm': 'Confirmar',
- 'common.ok': 'Aceptar',
- 'common.yes': 'Sí',
- 'common.no': 'No',
- 'common.or': 'o',
- 'common.none': 'Ninguno',
- 'common.date': 'Fecha',
- 'common.rename': 'Renombrar',
- 'common.name': 'Nombre',
- 'common.email': 'Correo',
- 'common.password': 'Contraseña',
- 'common.saving': 'Guardando...',
- 'common.update': 'Actualizar',
- 'common.change': 'Cambiar',
- 'common.uploading': 'Subiendo…',
- 'common.backToPlanning': 'Volver a la planificación',
- 'common.reset': 'Restablecer',
-
- // Navbar
- 'nav.trip': 'Viaje',
- 'nav.share': 'Compartir',
- 'nav.settings': 'Ajustes',
- 'nav.admin': 'Administración',
- 'nav.logout': 'Cerrar sesión',
- 'nav.lightMode': 'Modo claro',
- 'nav.darkMode': 'Modo oscuro',
- 'nav.autoMode': 'Modo automático',
- 'nav.administrator': 'Administrador',
- 'nav.myTrips': 'Mis viajes',
-
- // Dashboard
- 'dashboard.title': 'Mis viajes',
- 'dashboard.subtitle.loading': 'Cargando viajes...',
- 'dashboard.subtitle.trips': '{count} viajes ({archived} archivados)',
- 'dashboard.subtitle.empty': 'Empieza tu primer viaje',
- 'dashboard.subtitle.activeOne': '{count} viaje activo',
- 'dashboard.subtitle.activeMany': '{count} viajes activos',
- 'dashboard.subtitle.archivedSuffix': ' · {count} archivados',
- 'dashboard.newTrip': 'Nuevo viaje',
- 'dashboard.currency': 'Divisa',
- 'dashboard.timezone': 'Zonas horarias',
- 'dashboard.localTime': 'Hora local',
- 'dashboard.emptyTitle': 'Aún no hay viajes',
- 'dashboard.emptyText': 'Crea tu primer viaje y empieza a planificar',
- 'dashboard.emptyButton': 'Crear primer viaje',
- 'dashboard.nextTrip': 'Próximo viaje',
- 'dashboard.shared': 'Compartido',
- 'dashboard.sharedBy': 'Compartido por {name}',
- 'dashboard.days': 'Días',
- 'dashboard.places': 'Lugares',
- 'dashboard.archive': 'Archivar',
- 'dashboard.restore': 'Restaurar',
- 'dashboard.archived': 'Archivado',
- 'dashboard.status.ongoing': 'En curso',
- 'dashboard.status.today': 'Hoy',
- 'dashboard.status.tomorrow': 'Mañana',
- 'dashboard.status.past': 'Pasado',
- 'dashboard.status.daysLeft': 'Quedan {count} días',
- 'dashboard.toast.loadError': 'No se pudieron cargar los viajes',
- 'dashboard.toast.created': '¡Viaje creado correctamente!',
- 'dashboard.toast.createError': 'No se pudo crear el viaje',
- 'dashboard.toast.updated': '¡Viaje actualizado!',
- 'dashboard.toast.updateError': 'No se pudo actualizar el viaje',
- 'dashboard.toast.deleted': 'Viaje eliminado',
- 'dashboard.toast.deleteError': 'No se pudo eliminar el viaje',
- 'dashboard.toast.archived': 'Viaje archivado',
- 'dashboard.toast.archiveError': 'No se pudo archivar el viaje',
- 'dashboard.toast.restored': 'Viaje restaurado',
- 'dashboard.toast.restoreError': 'No se pudo restaurar el viaje',
- 'dashboard.confirm.delete': '¿Eliminar el viaje "{title}"? Todos los lugares y planes se borrarán permanentemente.',
- 'dashboard.editTrip': 'Editar viaje',
- 'dashboard.createTrip': 'Crear nuevo viaje',
- 'dashboard.tripTitle': 'Título',
- 'dashboard.tripTitlePlaceholder': 'p. ej. Verano en Japón',
- 'dashboard.tripDescription': 'Descripción',
- 'dashboard.tripDescriptionPlaceholder': '¿De qué trata este viaje?',
- 'dashboard.startDate': 'Fecha de inicio',
- 'dashboard.endDate': 'Fecha de fin',
- 'dashboard.noDateHint': 'Sin fecha definida: se crearán 7 días por defecto. Puedes cambiarlo cuando quieras.',
- 'dashboard.coverImage': 'Imagen de portada',
- 'dashboard.addCoverImage': 'Añadir imagen de portada',
- 'dashboard.coverSaved': 'Imagen de portada guardada',
- 'dashboard.coverUploadError': 'Error al subir la imagen',
- 'dashboard.coverRemoveError': 'Error al eliminar la imagen',
- 'dashboard.titleRequired': 'El título es obligatorio',
- 'dashboard.endDateError': 'La fecha de fin debe ser posterior a la de inicio',
-
- // Settings
- 'settings.title': 'Ajustes',
- 'settings.subtitle': 'Configura tus ajustes personales',
- 'settings.map': 'Mapa',
- 'settings.mapTemplate': 'Plantilla del mapa',
- 'settings.mapTemplatePlaceholder.select': 'Seleccionar plantilla...',
- 'settings.mapDefaultHint': 'Déjalo vacío para OpenStreetMap (por defecto)',
- 'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
- 'settings.mapHint': 'Plantilla de URL para los mosaicos del mapa',
- 'settings.latitude': 'Latitud',
- 'settings.longitude': 'Longitud',
- 'settings.saveMap': 'Guardar mapa',
- 'settings.apiKeys': 'Claves API',
- 'settings.mapsKey': 'Clave API de Google Maps',
- 'settings.mapsKeyHint': 'Necesaria para buscar lugares. Consíguela en console.cloud.google.com',
- 'settings.weatherKey': 'Clave API de OpenWeatherMap',
- 'settings.weatherKeyHint': 'Para datos meteorológicos. Gratis en openweathermap.org/api',
- 'settings.keyPlaceholder': 'Introduce la clave...',
- 'settings.configured': 'Configurado',
- 'settings.saveKeys': 'Guardar claves',
- 'settings.display': 'Visualización',
- 'settings.colorMode': 'Modo de color',
- 'settings.light': 'Claro',
- 'settings.dark': 'Oscuro',
- 'settings.auto': 'Automático',
- 'settings.language': 'Idioma',
- 'settings.temperature': 'Unidad de temperatura',
- 'settings.timeFormat': 'Formato de hora',
- 'settings.routeCalculation': 'Cálculo de ruta',
- 'settings.on': 'Activado',
- 'settings.off': 'Desactivado',
- 'settings.account': 'Cuenta',
- 'settings.username': 'Usuario',
- 'settings.email': 'Correo',
- 'settings.role': 'Rol',
- 'settings.roleAdmin': 'Administrador',
- 'settings.oidcLinked': 'Vinculado con',
- 'settings.changePassword': 'Cambiar contraseña',
- 'settings.currentPassword': 'Contraseña actual',
- 'settings.newPassword': 'Nueva contraseña',
- 'settings.confirmPassword': 'Confirmar nueva contraseña',
- 'settings.updatePassword': 'Actualizar contraseña',
- 'settings.passwordRequired': 'Introduce la contraseña actual y la nueva',
- 'settings.passwordTooShort': 'La contraseña debe tener al menos 8 caracteres',
- 'settings.passwordMismatch': 'Las contraseñas no coinciden',
- 'settings.passwordChanged': 'Contraseña cambiada correctamente',
- 'settings.deleteAccount': 'Eliminar cuenta',
- 'settings.deleteAccountTitle': '¿Eliminar tu cuenta?',
- 'settings.deleteAccountWarning': 'Tu cuenta y todos tus viajes, lugares y archivos se eliminarán permanentemente. Esta acción no se puede deshacer.',
- 'settings.deleteAccountConfirm': 'Eliminar permanentemente',
- 'settings.deleteBlockedTitle': 'No es posible eliminarla',
- 'settings.deleteBlockedMessage': 'Eres el único administrador. Asciende a otro usuario a administrador antes de eliminar tu cuenta.',
- 'settings.roleUser': 'Usuario',
- 'settings.saveProfile': 'Guardar perfil',
- 'settings.toast.mapSaved': 'Ajustes del mapa guardados',
- 'settings.toast.keysSaved': 'Claves API guardadas',
- 'settings.toast.displaySaved': 'Ajustes de visualización guardados',
- 'settings.toast.profileSaved': 'Perfil guardado',
- 'settings.uploadAvatar': 'Subir foto de perfil',
- 'settings.removeAvatar': 'Eliminar foto de perfil',
- 'settings.avatarUploaded': 'Foto de perfil actualizada',
- 'settings.avatarRemoved': 'Foto de perfil eliminada',
- 'settings.avatarError': 'Falló la subida',
-
- // Login
- 'login.error': 'Inicio de sesión fallido. Revisa tus credenciales.',
- 'login.tagline': 'Tus viajes.\nTu plan.',
- 'login.description': 'Planifica viajes en colaboración con mapas interactivos, presupuestos y sincronización en tiempo real.',
- 'login.features.maps': 'Mapas interactivos',
- 'login.features.mapsDesc': 'Google Places, rutas y agrupación',
- 'login.features.realtime': 'Sincronización en tiempo real',
- 'login.features.realtimeDesc': 'Planificad juntos mediante WebSocket',
- 'login.features.budget': 'Control de presupuesto',
- 'login.features.budgetDesc': 'Categorías, gráficos y costes por persona',
- 'login.features.collab': 'Colaboración',
- 'login.features.collabDesc': 'Multiusuario con viajes compartidos',
- 'login.features.packing': 'Listas de equipaje',
- 'login.features.packingDesc': 'Categorías, progreso y sugerencias',
- 'login.features.bookings': 'Reservas',
- 'login.features.bookingsDesc': 'Vuelos, hoteles, restaurantes y más',
- 'login.features.files': 'Documentos',
- 'login.features.filesDesc': 'Sube y gestiona documentos',
- 'login.features.routes': 'Rutas inteligentes',
- 'login.features.routesDesc': 'Optimización automática y exportación a Google Maps',
- 'login.selfHosted': 'Autoalojado · Código abierto · Tus datos siguen siendo tuyos',
- 'login.title': 'Iniciar sesión',
- 'login.subtitle': 'Bienvenido de nuevo',
- 'login.signingIn': 'Iniciando sesión…',
- 'login.signIn': 'Entrar',
- 'login.createAdmin': 'Crear cuenta de administrador',
- 'login.createAdminHint': 'Configura la primera cuenta administradora de NOMAD.',
- 'login.createAccount': 'Crear cuenta',
- 'login.createAccountHint': 'Crea una cuenta nueva.',
- 'login.creating': 'Creando…',
- 'login.noAccount': '¿No tienes cuenta?',
- 'login.hasAccount': '¿Ya tienes cuenta?',
- 'login.register': 'Registrarse',
- 'login.emailPlaceholder': 'tu@correo.com',
- 'login.username': 'Usuario',
- 'login.oidc.registrationDisabled': 'El registro está desactivado. Contacta con tu administrador.',
- 'login.oidc.noEmail': 'No se recibió ningún correo del proveedor.',
- 'login.oidc.tokenFailed': 'La autenticación falló.',
- 'login.oidc.invalidState': 'Sesión no válida. Inténtalo de nuevo.',
- 'login.demoFailed': 'Falló el acceso a la demo',
- 'login.oidcSignIn': 'Entrar con {name}',
- 'login.demoHint': 'Prueba la demo: no necesitas registrarte',
-
- // Register
- 'register.passwordMismatch': 'Las contraseñas no coinciden',
- 'register.passwordTooShort': 'La contraseña debe tener al menos 6 caracteres',
- 'register.failed': 'Falló el registro',
- 'register.getStarted': 'Empezar',
- 'register.subtitle': 'Crea una cuenta y empieza a planificar tus viajes.',
- 'register.feature1': 'Planes de viaje ilimitados',
- 'register.feature2': 'Vista de mapa interactiva',
- 'register.feature3': 'Gestiona lugares y categorías',
- 'register.feature4': 'Haz seguimiento de las reservas',
- 'register.feature5': 'Crea listas de equipaje',
- 'register.feature6': 'Guarda fotos y archivos',
- 'register.createAccount': 'Crear cuenta',
- 'register.startPlanning': 'Empieza a planificar tu viaje',
- 'register.minChars': 'Mín. 6 caracteres',
- 'register.confirmPassword': 'Confirmar contraseña',
- 'register.repeatPassword': 'Repetir contraseña',
- 'register.registering': 'Registrando...',
- 'register.register': 'Registrarse',
- 'register.hasAccount': '¿Ya tienes cuenta?',
- 'register.signIn': 'Iniciar sesión',
-
- // Admin
- 'admin.title': 'Administración',
- 'admin.subtitle': 'Gestión de usuarios y ajustes del sistema',
- 'admin.tabs.users': 'Usuarios',
- 'admin.tabs.categories': 'Categorías',
- 'admin.tabs.backup': 'Copia de seguridad',
- 'admin.stats.users': 'Usuarios',
- 'admin.stats.trips': 'Viajes',
- 'admin.stats.places': 'Lugares',
- 'admin.stats.photos': 'Fotos',
- 'admin.stats.files': 'Archivos',
- 'admin.table.user': 'Usuario',
- 'admin.table.email': 'Correo',
- 'admin.table.role': 'Rol',
- 'admin.table.created': 'Creado',
- 'admin.table.lastLogin': 'Último acceso',
- 'admin.table.actions': 'Acciones',
- 'admin.you': '(Tú)',
- 'admin.editUser': 'Editar usuario',
- 'admin.newPassword': 'Nueva contraseña',
- 'admin.newPasswordHint': 'Déjalo vacío para mantener la contraseña actual',
- 'admin.deleteUser': '¿Eliminar al usuario "{name}"? Todos sus viajes se borrarán permanentemente.',
- 'admin.deleteUserTitle': 'Eliminar usuario',
- 'admin.newPasswordPlaceholder': 'Introduce una nueva contraseña…',
- 'admin.toast.loadError': 'No se pudieron cargar los datos de administración',
- 'admin.toast.userUpdated': 'Usuario actualizado',
- 'admin.toast.updateError': 'No se pudo actualizar',
- 'admin.toast.userDeleted': 'Usuario eliminado',
- 'admin.toast.deleteError': 'No se pudo eliminar',
- 'admin.toast.cannotDeleteSelf': 'No puedes eliminar tu propia cuenta',
- 'admin.toast.userCreated': 'Usuario creado',
- 'admin.toast.createError': 'No se pudo crear el usuario',
- 'admin.toast.fieldsRequired': 'Usuario, correo y contraseña son obligatorios',
- 'admin.createUser': 'Crear usuario',
- 'admin.tabs.settings': 'Ajustes',
- 'admin.allowRegistration': 'Permitir el registro',
- 'admin.allowRegistrationHint': 'Los nuevos usuarios pueden registrarse por sí mismos',
- 'admin.apiKeys': 'Claves API',
- 'admin.apiKeysHint': 'Opcional. Activa datos ampliados de lugares, como fotos y previsión del tiempo.',
- 'admin.mapsKey': 'Clave API de Google Maps',
- 'admin.mapsKeyHint': 'Obligatoria para buscar lugares. Consíguela en console.cloud.google.com',
- 'admin.mapsKeyHintLong': 'Sin una clave API, la búsqueda de lugares usa OpenStreetMap. Con una clave de Google también se pueden cargar fotos, valoraciones y horarios de apertura. Consíguela en console.cloud.google.com.',
- 'admin.recommended': 'Recomendado',
- 'admin.weatherKey': 'Clave API de OpenWeatherMap',
- 'admin.weatherKeyHint': 'Para datos meteorológicos. Gratis en openweathermap.org',
- 'admin.validateKey': 'Probar',
- 'admin.keyValid': 'Conectado',
- 'admin.keyInvalid': 'No válida',
- 'admin.keySaved': 'Claves API guardadas',
- 'admin.oidcTitle': 'Inicio de sesión único (OIDC)',
- 'admin.oidcSubtitle': 'Permite iniciar sesión mediante proveedores externos como Google, Apple, Authentik o Keycloak.',
- 'admin.oidcDisplayName': 'Nombre visible',
- 'admin.oidcIssuer': 'URL del emisor',
- 'admin.oidcIssuerHint': 'La URL Issuer de OpenID Connect del proveedor. Ej.: https://accounts.google.com',
- 'admin.oidcSaved': 'Configuración OIDC guardada',
-
- // File Types
- 'admin.fileTypes': 'Tipos de archivo permitidos',
- 'admin.fileTypesHint': 'Configura qué tipos de archivo pueden subir los usuarios.',
- 'admin.fileTypesFormat': 'Extensiones separadas por comas (p. ej. jpg,png,pdf,doc). Usa * para permitir todos los tipos.',
- 'admin.fileTypesSaved': 'Ajustes de tipos de archivo guardados',
-
- // Addons
- 'admin.tabs.addons': 'Complementos',
- 'admin.addons.title': 'Complementos',
- 'admin.addons.subtitle': 'Activa o desactiva funciones para personalizar tu experiencia en NOMAD.',
- 'admin.addons.subtitleBefore': 'Activa o desactiva funciones para personalizar tu experiencia en ',
- 'admin.addons.subtitleAfter': '.',
- 'admin.addons.enabled': 'Activo',
- 'admin.addons.disabled': 'Desactivado',
- 'admin.addons.type.trip': 'Viaje',
- 'admin.addons.type.global': 'Global',
- 'admin.addons.tripHint': 'Disponible como pestaña dentro de cada viaje',
- 'admin.addons.globalHint': 'Disponible como sección independiente en la navegación principal',
- 'admin.addons.toast.updated': 'Complemento actualizado',
- 'admin.addons.toast.error': 'No se pudo actualizar el complemento',
- 'admin.addons.noAddons': 'No hay complementos disponibles',
- 'admin.weather.title': 'Datos meteorológicos',
- 'admin.weather.badge': 'Desde el 24 de marzo de 2026',
- 'admin.weather.description': 'NOMAD utiliza Open-Meteo como fuente de datos meteorológicos. Open-Meteo es un servicio meteorológico gratuito y de código abierto: no requiere clave API.',
- 'admin.weather.forecast': 'Pronóstico de 16 días',
- 'admin.weather.forecastDesc': 'Antes eran 5 días (OpenWeatherMap)',
- 'admin.weather.climate': 'Datos climáticos históricos',
- 'admin.weather.climateDesc': 'Promedios de los últimos 85 años para fechas posteriores al pronóstico de 16 días',
- 'admin.weather.requests': '10.000 solicitudes / día',
- 'admin.weather.requestsDesc': 'Gratis, sin necesidad de clave API',
- 'admin.weather.locationHint': 'El tiempo se basa en el primer lugar con coordenadas de cada día. Si no hay ningún lugar asignado a un día, se usa como referencia cualquier lugar de la lista.',
-
- // GitHub
- 'admin.tabs.github': 'GitHub',
- 'admin.github.title': 'Historial de versiones',
- 'admin.github.subtitle': 'Últimas novedades de {repo}',
- 'admin.github.latest': 'Última',
- 'admin.github.prerelease': 'Prelanzamiento',
- 'admin.github.showDetails': 'Mostrar detalles',
- 'admin.github.hideDetails': 'Ocultar detalles',
- 'admin.github.loadMore': 'Cargar más',
- 'admin.github.loading': 'Cargando...',
- 'admin.github.error': 'No se pudieron cargar las versiones',
- 'admin.github.by': 'por',
- 'admin.update.available': 'Actualización disponible',
- 'admin.update.text': 'NOMAD {version} está disponible. Estás usando {current}.',
- 'admin.update.button': 'Ver en GitHub',
- 'admin.update.install': 'Instalar actualización',
- 'admin.update.confirmTitle': '¿Instalar actualización?',
- 'admin.update.confirmText': 'NOMAD se actualizará de {current} a {version}. Después, el servidor se reiniciará automáticamente.',
- 'admin.update.dataInfo': 'Todos tus datos (viajes, usuarios, claves API, subidas, Vacay, Atlas, presupuestos) se conservarán.',
- 'admin.update.warning': 'La app estará brevemente no disponible durante el reinicio.',
- 'admin.update.confirm': 'Actualizar ahora',
- 'admin.update.installing': 'Actualizando…',
- 'admin.update.success': '¡Actualización instalada! El servidor se está reiniciando…',
- 'admin.update.failed': 'La actualización falló',
- 'admin.update.backupHint': 'Recomendamos crear una copia de seguridad antes de actualizar.',
- 'admin.update.backupLink': 'Ir a Copia de seguridad',
- 'admin.update.howTo': 'Cómo actualizar',
- 'admin.update.dockerText': 'Tu instancia de NOMAD se ejecuta en Docker. Para actualizar a {version}, ejecuta los siguientes comandos en tu servidor:',
- 'admin.update.reloadHint': 'Recarga la página en unos segundos.',
-
- // Vacay addon
- 'vacay.subtitle': 'Planifica y gestiona días de vacaciones',
- 'vacay.settings': 'Ajustes',
- 'vacay.year': 'Año',
- 'vacay.addYear': 'Añadir año',
- 'vacay.removeYear': 'Eliminar año',
- 'vacay.removeYearConfirm': '¿Eliminar {year}?',
- 'vacay.removeYearHint': 'Todas las vacaciones y festivos de empresa de este año se borrarán permanentemente.',
- 'vacay.remove': 'Eliminar',
- 'vacay.persons': 'Personas',
- 'vacay.noPersons': 'No se han añadido personas',
- 'vacay.addPerson': 'Añadir persona',
- 'vacay.editPerson': 'Editar persona',
- 'vacay.removePerson': 'Eliminar persona',
- 'vacay.removePersonConfirm': '¿Eliminar a {name}?',
- 'vacay.removePersonHint': 'Todas las vacaciones de esta persona se borrarán permanentemente.',
- 'vacay.personName': 'Nombre',
- 'vacay.personNamePlaceholder': 'Introduce un nombre',
- 'vacay.color': 'Color',
- 'vacay.add': 'Añadir',
- 'vacay.legend': 'Leyenda',
- 'vacay.publicHoliday': 'Festivo',
- 'vacay.companyHoliday': 'Festivo de empresa',
- 'vacay.weekend': 'Fin de semana',
- 'vacay.modeVacation': 'Vacaciones',
- 'vacay.modeCompany': 'Festivo de empresa',
- 'vacay.entitlement': 'Derecho',
- 'vacay.entitlementDays': 'Días',
- 'vacay.used': 'Usados',
- 'vacay.remaining': 'Restantes',
- 'vacay.carriedOver': 'de {year}',
- 'vacay.blockWeekends': 'Bloquear fines de semana',
- 'vacay.blockWeekendsHint': 'Impide marcar vacaciones en sábados y domingos',
- 'vacay.publicHolidays': 'Festivos',
- 'vacay.publicHolidaysHint': 'Marcar festivos en el calendario',
- 'vacay.selectCountry': 'Seleccionar país',
- 'vacay.selectRegion': 'Seleccionar región (opcional)',
- 'vacay.companyHolidays': 'Festivos de empresa',
- 'vacay.companyHolidaysHint': 'Permitir marcar días festivos comunes de la empresa',
- 'vacay.companyHolidaysNoDeduct': 'Los festivos de empresa no descuentan días de vacaciones.',
- 'vacay.carryOver': 'Arrastrar saldo',
- 'vacay.carryOverHint': 'Trasladar automáticamente los días restantes al año siguiente',
- 'vacay.sharing': 'Compartir',
- 'vacay.sharingHint': 'Comparte tu calendario de vacaciones con otros usuarios de NOMAD',
- 'vacay.owner': 'Propietario',
- 'vacay.shareEmailPlaceholder': 'Correo electrónico del usuario de NOMAD',
- 'vacay.shareSuccess': 'Plan compartido correctamente',
- 'vacay.shareError': 'No se pudo compartir el plan',
- 'vacay.dissolve': 'Deshacer fusión',
- 'vacay.dissolveHint': 'Separar de nuevo los calendarios. Tus entradas se conservarán.',
- 'vacay.dissolveAction': 'Disolver',
- 'vacay.dissolved': 'Calendario separado',
- 'vacay.fusedWith': 'Fusionado con',
- 'vacay.you': 'tú',
- 'vacay.noData': 'Sin datos',
- 'vacay.changeColor': 'Cambiar color',
- 'vacay.inviteUser': 'Invitar usuario',
- 'vacay.inviteHint': 'Invita a otro usuario de NOMAD a compartir un calendario combinado de vacaciones.',
- 'vacay.selectUser': 'Seleccionar usuario',
- 'vacay.sendInvite': 'Enviar invitación',
- 'vacay.inviteSent': 'Invitación enviada',
- 'vacay.inviteError': 'No se pudo enviar la invitación',
- 'vacay.pending': 'pendiente',
- 'vacay.noUsersAvailable': 'No hay usuarios disponibles',
- 'vacay.accept': 'Aceptar',
- 'vacay.decline': 'Rechazar',
- 'vacay.acceptFusion': 'Aceptar y fusionar',
- 'vacay.inviteTitle': 'Solicitud de fusión',
- 'vacay.inviteWantsToFuse': 'quiere compartir un calendario de vacaciones contigo.',
- 'vacay.fuseInfo1': 'Ambos veréis todas las entradas de vacaciones en un único calendario compartido.',
- 'vacay.fuseInfo2': 'Ambas partes pueden crear y editar entradas mutuamente.',
- 'vacay.fuseInfo3': 'Ambas partes pueden borrar entradas y cambiar el número de días de vacaciones disponibles.',
- 'vacay.fuseInfo4': 'Ajustes como festivos y festivos de empresa se comparten.',
- 'vacay.fuseInfo5': 'La fusión puede disolverse en cualquier momento por cualquiera de las partes. Tus entradas se conservarán.',
-
- // Atlas addon
- 'atlas.subtitle': 'Tu huella viajera por el mundo',
- 'atlas.countries': 'Países',
- 'atlas.trips': 'Viajes',
- 'atlas.places': 'Lugares',
- 'atlas.days': 'Días',
- 'atlas.visitedCountries': 'Países visitados',
- 'atlas.cities': 'Ciudades',
- 'atlas.noData': 'Aún no hay datos de viaje',
- 'atlas.noDataHint': 'Crea un viaje y añade lugares para ver tu mapa del mundo',
- 'atlas.lastTrip': 'Último viaje',
- 'atlas.nextTrip': 'Próximo viaje',
- 'atlas.daysLeft': 'días restantes',
- 'atlas.streak': 'Racha',
- 'atlas.year': 'año',
- 'atlas.years': 'años',
- 'atlas.yearInRow': 'año seguido',
- 'atlas.yearsInRow': 'años seguidos',
- 'atlas.tripIn': 'viaje en',
- 'atlas.tripsIn': 'viajes en',
- 'atlas.since': 'desde',
- 'atlas.europe': 'Europa',
- 'atlas.asia': 'Asia',
- 'atlas.northAmerica': 'América del Norte',
- 'atlas.southAmerica': 'América del Sur',
- 'atlas.africa': 'África',
- 'atlas.oceania': 'Oceanía',
- 'atlas.other': 'Otros',
- 'atlas.firstVisit': 'Primer viaje',
- 'atlas.lastVisitLabel': 'Último viaje',
- 'atlas.tripSingular': 'Viaje',
- 'atlas.tripPlural': 'Viajes',
- 'atlas.placeVisited': 'Lugar visitado',
- 'atlas.placesVisited': 'Lugares visitados',
-
- // Trip Planner
- 'trip.tabs.plan': 'Plan',
- 'trip.tabs.reservations': 'Reservas',
- 'trip.tabs.reservationsShort': 'Reservas',
- 'trip.tabs.packing': 'Lista de equipaje',
- 'trip.tabs.packingShort': 'Equipaje',
- 'trip.tabs.budget': 'Presupuesto',
- 'trip.tabs.memories': 'Recuerdos',
- 'trip.tabs.files': 'Archivos',
- 'trip.loading': 'Cargando viaje...',
- 'trip.mobilePlan': 'Plan',
- 'trip.mobilePlaces': 'Lugares',
- 'trip.toast.placeUpdated': 'Lugar actualizado',
- 'trip.toast.placeAdded': 'Lugar añadido',
- 'trip.toast.placeDeleted': 'Lugar eliminado',
- 'trip.toast.selectDay': 'Selecciona primero un día',
- 'trip.toast.assignedToDay': 'Lugar asignado al día',
- 'trip.toast.reorderError': 'No se pudo reordenar',
- 'trip.toast.reservationUpdated': 'Reserva actualizada',
- 'trip.toast.reservationAdded': 'Reserva añadida',
- 'trip.toast.deleted': 'Eliminado',
- 'trip.confirm.deletePlace': '¿Seguro que quieres eliminar este lugar?',
-
- // Day Plan Sidebar
- 'dayplan.emptyDay': 'No hay lugares planificados para este día',
- 'dayplan.addNote': 'Añadir nota',
- 'dayplan.editNote': 'Editar nota',
- 'dayplan.noteAdd': 'Añadir nota',
- 'dayplan.noteEdit': 'Editar nota',
- 'dayplan.noteTitle': 'Nota',
- 'dayplan.noteSubtitle': 'Nota diaria',
- 'dayplan.totalCost': 'Coste total',
- 'dayplan.days': 'Días',
- 'dayplan.dayN': 'Día {n}',
- 'dayplan.calculating': 'Calculando...',
- 'dayplan.route': 'Ruta',
- 'dayplan.optimize': 'Optimizar',
- 'dayplan.optimized': 'Ruta optimizada',
- 'dayplan.routeError': 'No se pudo calcular la ruta',
- 'dayplan.toast.needTwoPlaces': 'Se necesitan al menos dos lugares para optimizar la ruta',
- 'dayplan.toast.routeOptimized': 'Ruta optimizada',
- 'dayplan.toast.noGeoPlaces': 'No se encontraron lugares con coordenadas para calcular la ruta',
- 'dayplan.confirmed': 'Confirmado',
- 'dayplan.pendingRes': 'Pendiente',
- 'dayplan.pdf': 'PDF',
- 'dayplan.pdfTooltip': 'Exportar plan diario como PDF',
- 'dayplan.pdfError': 'No se pudo exportar el PDF',
-
- // Places Sidebar
- 'places.addPlace': 'Añadir lugar/actividad',
- 'places.assignToDay': '¿A qué día añadirlo?',
- 'places.all': 'Todo',
- 'places.unplanned': 'Sin planificar',
- 'places.search': 'Buscar lugares...',
- 'places.allCategories': 'Todas las categorías',
- 'places.count': '{count} lugares',
- 'places.countSingular': '1 lugar',
- 'places.allPlanned': 'Todos los lugares están planificados',
- 'places.noneFound': 'No se encontraron lugares',
- 'places.editPlace': 'Editar lugar',
- 'places.formName': 'Nombre',
- 'places.formNamePlaceholder': 'p. ej. Torre Eiffel',
- 'places.formDescription': 'Descripción',
- 'places.formDescriptionPlaceholder': 'Descripción breve...',
- 'places.formAddress': 'Dirección',
- 'places.formAddressPlaceholder': 'Calle, ciudad, país',
- 'places.formLat': 'Latitud (p. ej. 48.8566)',
- 'places.formLng': 'Longitud (p. ej. 2.3522)',
- 'places.formCategory': 'Categoría',
- 'places.noCategory': 'Sin categoría',
- 'places.categoryNamePlaceholder': 'Nombre de la categoría',
- 'places.formTime': 'Hora',
- 'places.startTime': 'Inicio',
- 'places.endTime': 'Fin',
- 'places.endTimeBeforeStart': 'La hora de fin es anterior a la de inicio',
- 'places.timeCollision': 'Solapamiento horario con:',
- 'places.formWebsite': 'Página web',
- 'places.formNotesPlaceholder': 'Notas personales...',
- 'places.formReservation': 'Reserva',
- 'places.reservationNotesPlaceholder': 'Notas de reserva, número de confirmación...',
- 'places.mapsSearchPlaceholder': 'Buscar lugares...',
- 'places.mapsSearchError': 'La búsqueda de lugares falló.',
- 'places.osmHint': 'Usando búsqueda con OpenStreetMap (sin fotos, horarios ni valoraciones). Añade una clave API de Google en Ajustes para obtener todos los detalles.',
- 'places.osmActive': 'Búsqueda mediante OpenStreetMap (sin fotos, valoraciones ni horarios). Añade una clave API de Google en Ajustes para datos ampliados.',
- 'places.categoryCreateError': 'No se pudo crear la categoría',
- 'places.nameRequired': 'Introduce un nombre',
- 'places.saveError': 'No se pudo guardar',
-
- // Place Inspector
- 'inspector.opened': 'Abierto',
- 'inspector.closed': 'Cerrado',
- 'inspector.openingHours': 'Horario de apertura',
- 'inspector.showHours': 'Mostrar horario',
- 'inspector.files': 'Archivos',
- 'inspector.filesCount': '{count} archivos',
- 'inspector.removeFromDay': 'Quitar del día',
- 'inspector.addToDay': 'Añadir al día',
- 'inspector.confirmedRes': 'Reserva confirmada',
- 'inspector.pendingRes': 'Reserva pendiente',
- 'inspector.google': 'Abrir en Google Maps',
- 'inspector.website': 'Abrir la web',
- 'inspector.addRes': 'Reserva',
- 'inspector.editRes': 'Editar reserva',
- 'inspector.participants': 'Participantes',
-
- // Reservations
- 'reservations.title': 'Reservas',
- 'reservations.empty': 'Aún no hay reservas',
- 'reservations.emptyHint': 'Añade reservas de vuelos, hoteles y más',
- 'reservations.add': 'Añadir reserva',
- 'reservations.addManual': 'Reserva manual',
- 'reservations.placeHint': 'Consejo: es mejor crear las reservas directamente desde un lugar para vincularlas con el plan del día.',
- 'reservations.confirmed': 'Confirmada',
- 'reservations.pending': 'Pendiente',
- 'reservations.summary': '{confirmed} confirmadas, {pending} pendientes',
- 'reservations.fromPlan': 'Del plan',
- 'reservations.showFiles': 'Mostrar archivos',
- 'reservations.editTitle': 'Editar reserva',
- 'reservations.status': 'Estado',
- 'reservations.datetime': 'Fecha y hora',
- 'reservations.startTime': 'Hora de inicio',
- 'reservations.endTime': 'Hora de fin',
- 'reservations.date': 'Fecha',
- 'reservations.time': 'Hora',
- 'reservations.timeAlt': 'Hora (alternativa, p. ej. 19:30)',
- 'reservations.notes': 'Notas',
- 'reservations.notesPlaceholder': 'Notas adicionales...',
- 'reservations.type.flight': 'Vuelo',
- 'reservations.type.hotel': 'Hotel',
- 'reservations.type.restaurant': 'Restaurante',
- 'reservations.type.train': 'Tren',
- 'reservations.type.car': 'Coche de alquiler',
- 'reservations.type.cruise': 'Crucero',
- 'reservations.type.event': 'Evento',
- 'reservations.type.tour': 'Tour',
- 'reservations.type.other': 'Otro',
- 'reservations.confirm.delete': '¿Seguro que quieres eliminar la reserva "{name}"?',
- 'reservations.toast.updated': 'Reserva actualizada',
- 'reservations.toast.removed': 'Reserva eliminada',
- 'reservations.toast.fileUploaded': 'Archivo subido',
- 'reservations.toast.uploadError': 'No se pudo subir',
- 'reservations.newTitle': 'Nueva reserva',
- 'reservations.bookingType': 'Tipo de reserva',
- 'reservations.titleLabel': 'Título',
- 'reservations.titlePlaceholder': 'p. ej. Lufthansa LH123, Hotel Adlon, ...',
- 'reservations.locationAddress': 'Ubicación / dirección',
- 'reservations.locationPlaceholder': 'Dirección, aeropuerto, hotel...',
- 'reservations.confirmationCode': 'Código de reserva',
- 'reservations.confirmationPlaceholder': 'p. ej. ABC12345',
- 'reservations.day': 'Día',
- 'reservations.noDay': 'Sin día',
- 'reservations.place': 'Lugar',
- 'reservations.noPlace': 'Sin lugar',
- 'reservations.pendingSave': 'se guardará…',
- 'reservations.uploading': 'Subiendo...',
- 'reservations.attachFile': 'Adjuntar archivo',
- 'reservations.toast.saveError': 'No se pudo guardar',
- 'reservations.toast.updateError': 'No se pudo actualizar',
- 'reservations.toast.deleteError': 'No se pudo eliminar',
- 'reservations.confirm.remove': '¿Eliminar la reserva de "{name}"?',
- 'reservations.linkAssignment': 'Vincular a una asignación del día',
- 'reservations.pickAssignment': 'Selecciona una asignación de tu plan...',
- 'reservations.noAssignment': 'Sin vínculo (independiente)',
-
- // Budget
- 'budget.title': 'Presupuesto',
- 'budget.emptyTitle': 'Aún no se ha creado ningún presupuesto',
- 'budget.emptyText': 'Crea categorías y entradas para planificar el presupuesto de tu viaje',
- 'budget.emptyPlaceholder': 'Introduce el nombre de la categoría...',
- 'budget.createCategory': 'Crear categoría',
- 'budget.category': 'Categoría',
- 'budget.categoryName': 'Nombre de la categoría',
- 'budget.table.name': 'Nombre',
- 'budget.table.total': 'Total',
- 'budget.table.persons': 'Personas',
- 'budget.table.days': 'Días',
- 'budget.table.perPerson': 'Por persona',
- 'budget.table.perDay': 'Por día',
- 'budget.table.perPersonDay': 'Por pers. / día',
- 'budget.table.note': 'Nota',
- 'budget.newEntry': 'Nueva entrada',
- 'budget.defaultEntry': 'Nueva entrada',
- 'budget.defaultCategory': 'Nueva categoría',
- 'budget.total': 'Total',
- 'budget.totalBudget': 'Presupuesto total',
- 'budget.byCategory': 'Por categoría',
- 'budget.editTooltip': 'Haz clic para editar',
- 'budget.confirm.deleteCategory': '¿Seguro que quieres eliminar la categoría "{name}" con {count} entradas?',
- 'budget.deleteCategory': 'Eliminar categoría',
- 'budget.perPerson': 'Por persona',
- 'budget.paid': 'Pagado',
- 'budget.open': 'Abrir',
- 'budget.noMembers': 'No hay miembros asignados',
-
- // Files
- 'files.title': 'Archivos',
- 'files.count': '{count} archivos',
- 'files.countSingular': '1 archivo',
- 'files.uploaded': '{count} archivos subidos',
- 'files.uploadError': 'La subida falló',
- 'files.dropzone': 'Arrastra aquí los archivos',
- 'files.dropzoneHint': 'o haz clic para explorar',
- 'files.allowedTypes': 'Imágenes, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Máx. 50 MB',
- 'files.uploading': 'Subiendo...',
- 'files.filterAll': 'Todo',
- 'files.filterPdf': 'PDF',
- 'files.filterImages': 'Imágenes',
- 'files.filterDocs': 'Documentos',
- 'files.filterCollab': 'Notas de colaboración',
- 'files.sourceCollab': 'Desde notas de colaboración',
- 'files.empty': 'Aún no hay archivos',
- 'files.emptyHint': 'Sube archivos para adjuntarlos a tu viaje',
- 'files.openTab': 'Abrir en una pestaña nueva',
- 'files.confirm.delete': '¿Seguro que quieres eliminar este archivo?',
- 'files.toast.deleted': 'Archivo eliminado',
- 'files.toast.deleteError': 'No se pudo eliminar el archivo',
- 'files.sourcePlan': 'Plan diario',
- 'files.sourceBooking': 'Reserva',
- 'files.attach': 'Adjuntar',
- 'files.pasteHint': 'También puedes pegar imágenes desde el portapapeles (Ctrl+V)',
-
- // Packing
- 'packing.title': 'Lista de equipaje',
- 'packing.empty': 'La lista de equipaje está vacía',
- 'packing.progress': '{packed} de {total} preparados ({percent}%)',
- 'packing.clearChecked': 'Eliminar {count} marcados',
- 'packing.clearCheckedShort': 'Eliminar {count}',
- 'packing.suggestions': 'Sugerencias',
- 'packing.suggestionsTitle': 'Añadir sugerencias',
- 'packing.allSuggested': 'Todas las sugerencias añadidas',
- 'packing.allPacked': '¡Todo preparado!',
- 'packing.addPlaceholder': 'Añadir nuevo elemento...',
- 'packing.categoryPlaceholder': 'Categoría...',
- 'packing.filterAll': 'Todo',
- 'packing.filterOpen': 'Pendientes',
- 'packing.filterDone': 'Hecho',
- 'packing.emptyTitle': 'La lista de equipaje está vacía',
- 'packing.emptyHint': 'Añade elementos o usa las sugerencias',
- 'packing.emptyFiltered': 'Ningún elemento coincide con este filtro',
- 'packing.menuRename': 'Renombrar',
- 'packing.menuCheckAll': 'Marcar todo',
- 'packing.menuUncheckAll': 'Desmarcar todo',
- 'packing.menuDeleteCat': 'Eliminar categoría',
- 'packing.changeCategory': 'Cambiar categoría',
- 'packing.confirm.clearChecked': '¿Seguro que quieres eliminar {count} elementos marcados?',
- 'packing.confirm.deleteCat': '¿Seguro que quieres eliminar la categoría "{name}" con {count} elementos?',
- 'packing.defaultCategory': 'Otros',
- 'packing.toast.saveError': 'No se pudo guardar',
- 'packing.toast.deleteError': 'No se pudo eliminar',
- 'packing.toast.renameError': 'No se pudo renombrar',
- 'packing.toast.addError': 'No se pudo añadir',
-
- // Packing suggestions
- 'packing.suggestions.items': [
- { name: 'Pasaporte', category: 'Documentos' },
- { name: 'Documento de identidad', category: 'Documentos' },
- { name: 'Seguro de viaje', category: 'Documentos' },
- { name: 'Billetes de vuelo', category: 'Documentos' },
- { name: 'Tarjeta de crédito', category: 'Finanzas' },
- { name: 'Efectivo', category: 'Finanzas' },
- { name: 'Visado', category: 'Documentos' },
- { name: 'Camisetas', category: 'Ropa' },
- { name: 'Pantalones', category: 'Ropa' },
- { name: 'Ropa interior', category: 'Ropa' },
- { name: 'Calcetines', category: 'Ropa' },
- { name: 'Chaqueta', category: 'Ropa' },
- { name: 'Pijama', category: 'Ropa' },
- { name: 'Ropa de baño', category: 'Ropa' },
- { name: 'Impermeable', category: 'Ropa' },
- { name: 'Zapatos cómodos', category: 'Ropa' },
- { name: 'Cepillo de dientes', category: 'Aseo' },
- { name: 'Pasta de dientes', category: 'Aseo' },
- { name: 'Champú', category: 'Aseo' },
- { name: 'Desodorante', category: 'Aseo' },
- { name: 'Protector solar', category: 'Aseo' },
- { name: 'Maquinilla de afeitar', category: 'Aseo' },
- { name: 'Cargador', category: 'Electrónica' },
- { name: 'Batería externa', category: 'Electrónica' },
- { name: 'Auriculares', category: 'Electrónica' },
- { name: 'Adaptador de viaje', category: 'Electrónica' },
- { name: 'Cámara', category: 'Electrónica' },
- { name: 'Analgésicos', category: 'Salud' },
- { name: 'Tiritas', category: 'Salud' },
- { name: 'Desinfectante', category: 'Salud' },
- ],
-
- // Members / Sharing
- 'members.shareTrip': 'Compartir viaje',
- 'members.inviteUser': 'Invitar usuario',
- 'members.selectUser': 'Seleccionar usuario…',
- 'members.invite': 'Invitar',
- 'members.allHaveAccess': 'Todos los usuarios ya tienen acceso.',
- 'members.access': 'Acceso',
- 'members.person': 'persona',
- 'members.persons': 'personas',
- 'members.you': 'tú',
- 'members.owner': 'Propietario',
- 'members.leaveTrip': 'Abandonar viaje',
- 'members.removeAccess': 'Quitar acceso',
- 'members.confirmLeave': '¿Abandonar el viaje? Perderás el acceso.',
- 'members.confirmRemove': '¿Quitar el acceso de este usuario?',
- 'members.loadError': 'No se pudieron cargar los miembros',
- 'members.added': 'añadido',
- 'members.addError': 'No se pudo añadir',
- 'members.removed': 'Miembro eliminado',
- 'members.removeError': 'No se pudo eliminar',
-
- // Categories (Admin)
- 'categories.title': 'Categorías',
- 'categories.subtitle': 'Gestiona categorías para lugares',
- 'categories.new': 'Nueva categoría',
- 'categories.empty': 'Aún no hay categorías',
- 'categories.namePlaceholder': 'Nombre de la categoría',
- 'categories.icon': 'Icono',
- 'categories.color': 'Color',
- 'categories.customColor': 'Elegir color personalizado',
- 'categories.preview': 'Vista previa',
- 'categories.defaultName': 'Categoría',
- 'categories.update': 'Actualizar',
- 'categories.create': 'Crear',
- 'categories.confirm.delete': '¿Eliminar la categoría? Los lugares de esta categoría no se eliminarán.',
- 'categories.toast.loadError': 'No se pudieron cargar las categorías',
- 'categories.toast.nameRequired': 'Introduce un nombre',
- 'categories.toast.updated': 'Categoría actualizada',
- 'categories.toast.created': 'Categoría creada',
- 'categories.toast.saveError': 'No se pudo guardar',
- 'categories.toast.deleted': 'Categoría eliminada',
- 'categories.toast.deleteError': 'No se pudo eliminar',
-
- // Backup (Admin)
- 'backup.title': 'Copia de seguridad de datos',
- 'backup.subtitle': 'Base de datos y todos los archivos subidos',
- 'backup.refresh': 'Actualizar',
- 'backup.upload': 'Subir copia de seguridad',
- 'backup.uploading': 'Subiendo…',
- 'backup.create': 'Crear copia',
- 'backup.creating': 'Creando…',
- 'backup.empty': 'Aún no hay copias',
- 'backup.createFirst': 'Crear la primera copia',
- 'backup.download': 'Descargar',
- 'backup.restore': 'Restaurar',
- 'backup.confirm.restore': '¿Restaurar la copia "{name}"?\n\nTodos los datos actuales serán reemplazados por la copia.',
- 'backup.confirm.uploadRestore': '¿Subir y restaurar el archivo de copia "{name}"?\n\nTodos los datos actuales se sobrescribirán.',
- 'backup.confirm.delete': '¿Eliminar la copia "{name}"?',
- 'backup.toast.loadError': 'No se pudieron cargar las copias',
- 'backup.toast.created': 'Copia de seguridad creada correctamente',
- 'backup.toast.createError': 'No se pudo crear la copia',
- 'backup.toast.restored': 'Copia restaurada. La página se recargará…',
- 'backup.toast.restoreError': 'No se pudo restaurar',
- 'backup.toast.uploadError': 'No se pudo subir',
- 'backup.toast.deleted': 'Copia eliminada',
- 'backup.toast.deleteError': 'No se pudo eliminar',
- 'backup.toast.downloadError': 'La descarga falló',
- 'backup.toast.settingsSaved': 'Ajustes de copia automática guardados',
- 'backup.toast.settingsError': 'No se pudieron guardar los ajustes',
- 'backup.auto.title': 'Copia automática',
- 'backup.auto.subtitle': 'Copia de seguridad automática según una programación',
- 'backup.auto.enable': 'Activar copia automática',
- 'backup.auto.enableHint': 'Se crearán copias automáticamente según la frecuencia elegida',
- 'backup.auto.interval': 'Intervalo',
- 'backup.auto.keepLabel': 'Eliminar copias antiguas después de',
- 'backup.interval.hourly': 'Cada hora',
- 'backup.interval.daily': 'Diaria',
- 'backup.interval.weekly': 'Semanal',
- 'backup.interval.monthly': 'Mensual',
- 'backup.keep.1day': '1 día',
- 'backup.keep.3days': '3 días',
- 'backup.keep.7days': '7 días',
- 'backup.keep.14days': '14 días',
- 'backup.keep.30days': '30 días',
- 'backup.keep.forever': 'Conservar para siempre',
-
- // Photos
- 'photos.allDays': 'Todos los días',
- 'photos.title': 'Recuerdos',
- 'photos.noPhotos': 'Aún no hay fotos',
- 'photos.uploadHint': 'Sube y organiza las fotos compartidas de este viaje',
- 'photos.clickToSelect': 'o haz clic para seleccionar',
- 'photos.dropHere': 'Suelta aquí las fotos...',
- 'photos.dropTitle': 'Suelta aquí las fotos',
- 'photos.fileHint': 'JPG, PNG, GIF, WebP · máx. 10 MB · hasta 30 fotos',
- 'photos.selectedCount': '{count} foto(s) seleccionada(s)',
- 'photos.sharedAlbum': '{count} recuerdos en este álbum compartido',
- 'photos.sharedAlbumFor': '{count} recuerdos en {trip}',
- 'photos.allPlaces': 'Todos los lugares',
- 'photos.view.grid': 'Cuadrícula',
- 'photos.view.day': 'Por día',
- 'photos.view.place': 'Por lugar',
- 'photos.stats.total': 'Fotos',
- 'photos.stats.days': 'Días',
- 'photos.stats.places': 'Lugares',
- 'photos.stats.latest': 'Última subida',
- 'photos.sectionCount': '{count} foto(s)',
- 'photos.ungrouped': 'Sin clasificar',
- 'photos.featured': 'Recuerdo destacado',
- 'photos.coverFallback': 'Portada del álbum compartido',
- 'photos.coverHint': 'Una imagen destacada para este álbum de viaje',
- 'photos.mapTitle': 'Mapa de recuerdos',
- 'photos.mapHint': 'Explora los lugares vinculados en el mismo mapa que usamos en el plan',
- 'photos.mapEmpty': 'Vincula tus fotos a lugares para verlas ubicadas en el mapa.',
- 'photos.linkDay': 'Vincular día',
- 'photos.noDay': 'Sin día',
- 'photos.linkPlace': 'Vincular lugar',
- 'photos.noPlace': 'Sin lugar',
- 'photos.captionLabel': 'Pie de foto (para todas)',
- 'photos.captionPlaceholder': 'Pie de foto opcional...',
- 'photos.addCaption': 'Añadir un pie de foto...',
- 'photos.uploadN': 'Subida de {n} foto(s)',
- 'admin.addons.catalog.memories.name': 'Recuerdos',
- 'admin.addons.catalog.memories.description': 'Álbumes de fotos compartidos para cada viaje',
- 'admin.addons.catalog.packing.name': 'Equipaje',
- 'admin.addons.catalog.packing.description': 'Prepara tu equipaje con listas de comprobación para cada viaje',
- 'admin.addons.catalog.budget.name': 'Presupuesto',
- 'admin.addons.catalog.budget.description': 'Controla los gastos y planifica el presupuesto del viaje',
- 'admin.addons.catalog.documents.name': 'Documentos',
- 'admin.addons.catalog.documents.description': 'Guarda y gestiona la documentación del viaje',
- 'admin.addons.catalog.vacay.name': 'Vacaciones',
- 'admin.addons.catalog.vacay.description': 'Planificador personal de vacaciones con vista de calendario',
- 'admin.addons.catalog.atlas.name': 'Atlas',
- 'admin.addons.catalog.atlas.description': 'Mapa del mundo con los países visitados y estadísticas de viaje',
- 'admin.addons.catalog.collab.name': 'Colaboración',
- 'admin.addons.catalog.collab.description': 'Notas, encuestas y chat en tiempo real para organizar el viaje',
-
- // Backup restore modal
- 'backup.restoreConfirmTitle': '¿Restaurar copia?',
- 'backup.restoreWarning': 'Todos los datos actuales (viajes, lugares, usuarios, subidas) serán reemplazados permanentemente por la copia. Esta acción no se puede deshacer.',
- 'backup.restoreTip': 'Consejo: crea una copia del estado actual antes de restaurar.',
- 'backup.restoreConfirm': 'Sí, restaurar',
-
- // PDF
- 'pdf.travelPlan': 'Plan de viaje',
- 'pdf.planned': 'Planificado',
- 'pdf.costLabel': 'Coste EUR',
- 'pdf.preview': 'Vista previa PDF',
- 'pdf.saveAsPdf': 'Guardar como PDF',
-
- // Planner
- 'planner.places': 'Lugares',
- 'planner.bookings': 'Reservas',
- 'planner.packingList': 'Lista de equipaje',
- 'planner.documents': 'Documentos',
- 'planner.dayPlan': 'Plan por días',
- 'planner.reservations': 'Reservas',
- 'planner.minTwoPlaces': 'Se necesitan al menos 2 lugares con coordenadas',
- 'planner.noGeoPlaces': 'No hay lugares con coordenadas disponibles',
- 'planner.routeCalculated': 'Ruta calculada',
- 'planner.routeCalcFailed': 'No se pudo calcular la ruta',
- 'planner.routeError': 'Error al calcular la ruta',
- 'planner.routeOptimized': 'Ruta optimizada',
- 'planner.reservationUpdated': 'Reserva actualizada',
- 'planner.reservationAdded': 'Reserva añadida',
- 'planner.confirmDeleteReservation': '¿Eliminar reserva?',
- 'planner.reservationDeleted': 'Reserva eliminada',
- 'planner.days': 'Días',
- 'planner.allPlaces': 'Todos los lugares',
- 'planner.totalPlaces': '{n} lugares en total',
- 'planner.noDaysPlanned': 'Aún no hay días planificados',
- 'planner.editTrip': 'Editar viaje →',
- 'planner.placeOne': '1 lugar',
- 'planner.placeN': '{n} lugares',
- 'planner.addNote': 'Añadir nota',
- 'planner.noEntries': 'No hay entradas para este día',
- 'planner.addPlace': 'Añadir lugar/actividad',
- 'planner.addPlaceShort': '+ Añadir lugar/actividad',
- 'planner.resPending': 'Reserva pendiente · ',
- 'planner.resConfirmed': 'Reserva confirmada · ',
- 'planner.notePlaceholder': 'Nota…',
- 'planner.noteTimePlaceholder': 'Hora (opcional)',
- 'planner.noteExamplePlaceholder': 'p. ej. S3 a las 14:30 desde la estación central, ferry desde el muelle 7, pausa para comer…',
- 'planner.totalCost': 'Coste total',
- 'planner.searchPlaces': 'Buscar lugares…',
- 'planner.allCategories': 'Todas las categorías',
- 'planner.noPlacesFound': 'No se encontraron lugares',
- 'planner.addFirstPlace': 'Añadir el primer lugar',
- 'planner.noReservations': 'Sin reservas',
- 'planner.addFirstReservation': 'Añadir la primera reserva',
- 'planner.new': 'Nuevo',
- 'planner.addToDay': '+ Día',
- 'planner.calculating': 'Calculando…',
- 'planner.route': 'Ruta',
- 'planner.optimize': 'Optimizar',
- 'planner.openGoogleMaps': 'Abrir en Google Maps',
- 'planner.selectDayHint': 'Selecciona un día de la lista izquierda para ver su plan',
- 'planner.noPlacesForDay': 'Aún no hay lugares para este día',
- 'planner.addPlacesLink': 'Añadir lugares →',
- 'planner.minTotal': 'min en total',
- 'planner.noReservation': 'Sin reserva',
- 'planner.removeFromDay': 'Quitar del día',
- 'planner.addToThisDay': 'Añadir al día',
- 'planner.overview': 'Vista general',
- 'planner.noDays': 'No hay días todavía',
- 'planner.editTripToAddDays': 'Edita el viaje para añadir días',
- 'planner.dayCount': '{n} días',
- 'planner.clickToUnlock': 'Haz clic para desbloquear',
- 'planner.keepPosition': 'Mantener posición durante la optimización de ruta',
- 'planner.dayDetails': 'Detalles del día',
- 'planner.dayN': 'Día {n}',
- 'planner.notes': 'Notas',
- 'planner.addDayNote': 'Añadir notas para este día...',
-
- // Dashboard Stats
- 'stats.countries': 'Países',
- 'stats.cities': 'Ciudades',
- 'stats.trips': 'Viajes',
- 'stats.places': 'Lugares',
- 'stats.worldProgress': 'Progreso mundial',
- 'stats.visited': 'visitados',
- 'stats.remaining': 'restantes',
- 'stats.visitedCountries': 'Países visitados',
-
- // Day Detail Panel
- 'day.precipProb': 'Probabilidad de lluvia',
- 'day.precipitation': 'Precipitación',
- 'day.wind': 'Viento',
- 'day.sunrise': 'Amanecer',
- 'day.sunset': 'Atardecer',
- 'day.hourlyForecast': 'Pronóstico por horas',
- 'day.climateHint': 'Promedios históricos: el pronóstico real está disponible dentro de los 16 días previos a la fecha.',
- 'day.noWeather': 'No hay datos meteorológicos disponibles. Añade un lugar con coordenadas.',
- 'day.overview': 'Resumen diario',
- 'day.accommodation': 'Alojamiento',
- 'day.addAccommodation': 'Añadir alojamiento',
- 'day.hotelDayRange': 'Aplicar a los días',
- 'day.noPlacesForHotel': 'Añade primero lugares al viaje',
- 'day.allDays': 'Todos',
- 'day.checkIn': 'Check-in',
- 'day.checkOut': 'Check-out',
- 'day.confirmation': 'Confirmación',
- 'day.editAccommodation': 'Editar alojamiento',
- 'day.reservations': 'Reservas',
-
- // Collab Addon
- 'collab.tabs.chat': 'Mensajes',
- 'collab.tabs.notes': 'Notas',
- 'collab.tabs.polls': 'Encuestas',
- 'collab.whatsNext.title': 'Qué viene ahora',
- 'collab.whatsNext.today': 'Hoy',
- 'collab.whatsNext.tomorrow': 'Mañana',
- 'collab.whatsNext.empty': 'No hay actividades próximas',
- 'collab.whatsNext.until': 'hasta',
- 'collab.whatsNext.emptyHint': 'Las actividades con hora aparecerán aquí',
- 'collab.chat.send': 'Enviar',
- 'collab.chat.placeholder': 'Escribe un mensaje...',
- 'collab.chat.empty': 'Empieza la conversación',
- 'collab.chat.emptyHint': 'Los mensajes se comparten con todos los miembros del viaje',
- 'collab.chat.emptyDesc': 'Comparte ideas, planes y novedades con tu grupo de viaje',
- 'collab.chat.today': 'Hoy',
- 'collab.chat.yesterday': 'Ayer',
- 'collab.chat.deletedMessage': 'eliminó un mensaje',
- 'collab.chat.loadMore': 'Cargar mensajes anteriores',
- 'collab.chat.justNow': 'justo ahora',
- 'collab.chat.minutesAgo': 'hace {n} min',
- 'collab.chat.hoursAgo': 'hace {n} h',
- 'collab.notes.title': 'Notas',
- 'collab.notes.new': 'Nueva nota',
- 'collab.notes.empty': 'Aún no hay notas',
- 'collab.notes.emptyHint': 'Empieza a capturar ideas y planes',
- 'collab.notes.all': 'Todas',
- 'collab.notes.titlePlaceholder': 'Título de la nota',
- 'collab.notes.contentPlaceholder': 'Escribe algo...',
- 'collab.notes.categoryPlaceholder': 'Categoría',
- 'collab.notes.newCategory': 'Nueva categoría...',
- 'collab.notes.category': 'Categoría',
- 'collab.notes.noCategory': 'Sin categoría',
- 'collab.notes.color': 'Color',
- 'collab.notes.save': 'Guardar',
- 'collab.notes.cancel': 'Cancelar',
- 'collab.notes.edit': 'Editar',
- 'collab.notes.delete': 'Eliminar',
- 'collab.notes.pin': 'Fijar',
- 'collab.notes.unpin': 'Desfijar',
- 'collab.notes.daysAgo': 'hace {n} d',
- 'collab.notes.categorySettings': 'Gestionar categorías',
- 'collab.notes.create': 'Crear',
- 'collab.notes.website': 'Sitio web',
- 'collab.notes.websitePlaceholder': 'https://...',
- 'collab.notes.attachFiles': 'Adjuntar archivos',
- 'collab.notes.noCategoriesYet': 'Aún no hay categorías',
- 'collab.notes.emptyDesc': 'Crea una nota para empezar',
- 'collab.polls.title': 'Encuestas',
- 'collab.polls.new': 'Nueva encuesta',
- 'collab.polls.empty': 'Aún no hay encuestas',
- 'collab.polls.emptyHint': 'Pregunta al grupo y votad juntos',
- 'collab.polls.question': 'Pregunta',
- 'collab.polls.questionPlaceholder': '¿Qué deberíamos hacer?',
- 'collab.polls.addOption': '+ Añadir opción',
- 'collab.polls.optionPlaceholder': 'Opción {n}',
- 'collab.polls.create': 'Crear encuesta',
- 'collab.polls.close': 'Cerrar',
- 'collab.polls.closed': 'Cerrada',
- 'collab.polls.votes': '{n} votos',
- 'collab.polls.vote': '{n} voto',
- 'collab.polls.multipleChoice': 'Selección múltiple',
- 'collab.polls.multiChoice': 'Selección múltiple',
- 'collab.polls.deadline': 'Fecha límite',
- 'collab.polls.option': 'Opción',
- 'collab.polls.options': 'Opciones',
- 'collab.polls.delete': 'Eliminar',
- 'collab.polls.closedSection': 'Cerradas',
-}
-
-export default es
diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts
index 9e11b4e..650edc9 100644
--- a/client/src/i18n/translations/es.ts
+++ b/client/src/i18n/translations/es.ts
@@ -1 +1,1120 @@
-export { default } from './es.js'
+const es: Record = {
+ // Common
+ 'common.save': 'Guardar',
+ 'common.cancel': 'Cancelar',
+ 'common.delete': 'Eliminar',
+ 'common.edit': 'Editar',
+ 'common.add': 'Añadir',
+ 'common.loading': 'Cargando...',
+ 'common.error': 'Error',
+ 'common.back': 'Atrás',
+ 'common.all': 'Todo',
+ 'common.close': 'Cerrar',
+ 'common.open': 'Abrir',
+ 'common.upload': 'Subir',
+ 'common.search': 'Buscar',
+ 'common.confirm': 'Confirmar',
+ 'common.ok': 'Aceptar',
+ 'common.yes': 'Sí',
+ 'common.no': 'No',
+ 'common.or': 'o',
+ 'common.none': 'Ninguno',
+ 'common.date': 'Fecha',
+ 'common.rename': 'Renombrar',
+ 'common.name': 'Nombre',
+ 'common.email': 'Correo',
+ 'common.password': 'Contraseña',
+ 'common.saving': 'Guardando...',
+ 'common.update': 'Actualizar',
+ 'common.change': 'Cambiar',
+ 'common.uploading': 'Subiendo…',
+ 'common.backToPlanning': 'Volver a la planificación',
+ 'common.reset': 'Restablecer',
+
+ // Navbar
+ 'nav.trip': 'Viaje',
+ 'nav.share': 'Compartir',
+ 'nav.settings': 'Ajustes',
+ 'nav.admin': 'Administración',
+ 'nav.logout': 'Cerrar sesión',
+ 'nav.lightMode': 'Modo claro',
+ 'nav.darkMode': 'Modo oscuro',
+ 'nav.autoMode': 'Modo automático',
+ 'nav.administrator': 'Administrador',
+ 'nav.myTrips': 'Mis viajes',
+
+ // Dashboard
+ 'dashboard.title': 'Mis viajes',
+ 'dashboard.subtitle.loading': 'Cargando viajes...',
+ 'dashboard.subtitle.trips': '{count} viajes ({archived} archivados)',
+ 'dashboard.subtitle.empty': 'Empieza tu primer viaje',
+ 'dashboard.subtitle.activeOne': '{count} viaje activo',
+ 'dashboard.subtitle.activeMany': '{count} viajes activos',
+ 'dashboard.subtitle.archivedSuffix': ' · {count} archivados',
+ 'dashboard.newTrip': 'Nuevo viaje',
+ 'dashboard.currency': 'Divisa',
+ 'dashboard.timezone': 'Zonas horarias',
+ 'dashboard.localTime': 'Hora local',
+ 'dashboard.timezoneCustomTitle': 'Zona horaria personalizada',
+ 'dashboard.timezoneCustomLabelPlaceholder': 'Nombre (opcional)',
+ 'dashboard.timezoneCustomTzPlaceholder': 'ej. America/New_York',
+ 'dashboard.timezoneCustomAdd': 'Añadir',
+ 'dashboard.timezoneCustomErrorEmpty': 'Introduce una zona horaria',
+ 'dashboard.timezoneCustomErrorInvalid': 'Zona horaria no válida. Usa formato como Europe/Madrid',
+ 'dashboard.timezoneCustomErrorDuplicate': 'Ya añadida',
+ 'dashboard.emptyTitle': 'Aún no hay viajes',
+ 'dashboard.emptyText': 'Crea tu primer viaje y empieza a planificar',
+ 'dashboard.emptyButton': 'Crear primer viaje',
+ 'dashboard.nextTrip': 'Próximo viaje',
+ 'dashboard.shared': 'Compartido',
+ 'dashboard.sharedBy': 'Compartido por {name}',
+ 'dashboard.days': 'Días',
+ 'dashboard.places': 'Lugares',
+ 'dashboard.archive': 'Archivar',
+ 'dashboard.restore': 'Restaurar',
+ 'dashboard.archived': 'Archivado',
+ 'dashboard.status.ongoing': 'En curso',
+ 'dashboard.status.today': 'Hoy',
+ 'dashboard.status.tomorrow': 'Mañana',
+ 'dashboard.status.past': 'Pasado',
+ 'dashboard.status.daysLeft': 'Quedan {count} días',
+ 'dashboard.toast.loadError': 'No se pudieron cargar los viajes',
+ 'dashboard.toast.created': '¡Viaje creado correctamente!',
+ 'dashboard.toast.createError': 'No se pudo crear el viaje',
+ 'dashboard.toast.updated': '¡Viaje actualizado!',
+ 'dashboard.toast.updateError': 'No se pudo actualizar el viaje',
+ 'dashboard.toast.deleted': 'Viaje eliminado',
+ 'dashboard.toast.deleteError': 'No se pudo eliminar el viaje',
+ 'dashboard.toast.archived': 'Viaje archivado',
+ 'dashboard.toast.archiveError': 'No se pudo archivar el viaje',
+ 'dashboard.toast.restored': 'Viaje restaurado',
+ 'dashboard.toast.restoreError': 'No se pudo restaurar el viaje',
+ 'dashboard.confirm.delete': '¿Eliminar el viaje "{title}"? Todos los lugares y planes se borrarán permanentemente.',
+ 'dashboard.editTrip': 'Editar viaje',
+ 'dashboard.createTrip': 'Crear nuevo viaje',
+ 'dashboard.tripTitle': 'Título',
+ 'dashboard.tripTitlePlaceholder': 'p. ej. Verano en Japón',
+ 'dashboard.tripDescription': 'Descripción',
+ 'dashboard.tripDescriptionPlaceholder': '¿De qué trata este viaje?',
+ 'dashboard.startDate': 'Fecha de inicio',
+ 'dashboard.endDate': 'Fecha de fin',
+ 'dashboard.noDateHint': 'Sin fecha definida: se crearán 7 días por defecto. Puedes cambiarlo cuando quieras.',
+ 'dashboard.coverImage': 'Imagen de portada',
+ 'dashboard.addCoverImage': 'Añadir imagen de portada',
+ 'dashboard.coverSaved': 'Imagen de portada guardada',
+ 'dashboard.coverUploadError': 'Error al subir la imagen',
+ 'dashboard.coverRemoveError': 'Error al eliminar la imagen',
+ 'dashboard.titleRequired': 'El título es obligatorio',
+ 'dashboard.endDateError': 'La fecha de fin debe ser posterior a la de inicio',
+
+ // Settings
+ 'settings.title': 'Ajustes',
+ 'settings.subtitle': 'Configura tus ajustes personales',
+ 'settings.map': 'Mapa',
+ 'settings.mapTemplate': 'Plantilla del mapa',
+ 'settings.mapTemplatePlaceholder.select': 'Seleccionar plantilla...',
+ 'settings.mapDefaultHint': 'Déjalo vacío para OpenStreetMap (por defecto)',
+ 'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
+ 'settings.mapHint': 'Plantilla de URL para los mosaicos del mapa',
+ 'settings.latitude': 'Latitud',
+ 'settings.longitude': 'Longitud',
+ 'settings.saveMap': 'Guardar mapa',
+ 'settings.apiKeys': 'Claves API',
+ 'settings.mapsKey': 'Clave API de Google Maps',
+ 'settings.mapsKeyHint': 'Necesaria para buscar lugares. Consíguela en console.cloud.google.com',
+ 'settings.weatherKey': 'Clave API de OpenWeatherMap',
+ 'settings.weatherKeyHint': 'Para datos meteorológicos. Gratis en openweathermap.org/api',
+ 'settings.keyPlaceholder': 'Introduce la clave...',
+ 'settings.configured': 'Configurado',
+ 'settings.saveKeys': 'Guardar claves',
+ 'settings.display': 'Visualización',
+ 'settings.colorMode': 'Modo de color',
+ 'settings.light': 'Claro',
+ 'settings.dark': 'Oscuro',
+ 'settings.auto': 'Automático',
+ 'settings.language': 'Idioma',
+ 'settings.temperature': 'Unidad de temperatura',
+ 'settings.timeFormat': 'Formato de hora',
+ 'settings.routeCalculation': 'Cálculo de ruta',
+ 'settings.on': 'Activado',
+ 'settings.off': 'Desactivado',
+ 'settings.account': 'Cuenta',
+ 'settings.username': 'Usuario',
+ 'settings.email': 'Correo',
+ 'settings.role': 'Rol',
+ 'settings.roleAdmin': 'Administrador',
+ 'settings.oidcLinked': 'Vinculado con',
+ 'settings.changePassword': 'Cambiar contraseña',
+ 'settings.currentPassword': 'Contraseña actual',
+ 'settings.newPassword': 'Nueva contraseña',
+ 'settings.confirmPassword': 'Confirmar nueva contraseña',
+ 'settings.updatePassword': 'Actualizar contraseña',
+ 'settings.passwordRequired': 'Introduce la contraseña actual y la nueva',
+ 'settings.passwordTooShort': 'La contraseña debe tener al menos 8 caracteres',
+ 'settings.passwordMismatch': 'Las contraseñas no coinciden',
+ 'settings.passwordChanged': 'Contraseña cambiada correctamente',
+ 'settings.deleteAccount': 'Eliminar cuenta',
+ 'settings.deleteAccountTitle': '¿Eliminar tu cuenta?',
+ 'settings.deleteAccountWarning': 'Tu cuenta y todos tus viajes, lugares y archivos se eliminarán permanentemente. Esta acción no se puede deshacer.',
+ 'settings.deleteAccountConfirm': 'Eliminar permanentemente',
+ 'settings.deleteBlockedTitle': 'No es posible eliminarla',
+ 'settings.deleteBlockedMessage': 'Eres el único administrador. Asciende a otro usuario a administrador antes de eliminar tu cuenta.',
+ 'settings.roleUser': 'Usuario',
+ 'settings.saveProfile': 'Guardar perfil',
+ 'settings.toast.mapSaved': 'Ajustes del mapa guardados',
+ 'settings.toast.keysSaved': 'Claves API guardadas',
+ 'settings.toast.displaySaved': 'Ajustes de visualización guardados',
+ 'settings.toast.profileSaved': 'Perfil guardado',
+ 'settings.uploadAvatar': 'Subir foto de perfil',
+ 'settings.removeAvatar': 'Eliminar foto de perfil',
+ 'settings.avatarUploaded': 'Foto de perfil actualizada',
+ 'settings.avatarRemoved': 'Foto de perfil eliminada',
+ 'settings.avatarError': 'Falló la subida',
+
+ // Login
+ 'login.error': 'Inicio de sesión fallido. Revisa tus credenciales.',
+ 'login.tagline': 'Tus viajes.\nTu plan.',
+ 'login.description': 'Planifica viajes en colaboración con mapas interactivos, presupuestos y sincronización en tiempo real.',
+ 'login.features.maps': 'Mapas interactivos',
+ 'login.features.mapsDesc': 'Google Places, rutas y agrupación',
+ 'login.features.realtime': 'Sincronización en tiempo real',
+ 'login.features.realtimeDesc': 'Planificad juntos mediante WebSocket',
+ 'login.features.budget': 'Control de presupuesto',
+ 'login.features.budgetDesc': 'Categorías, gráficos y costes por persona',
+ 'login.features.collab': 'Colaboración',
+ 'login.features.collabDesc': 'Multiusuario con viajes compartidos',
+ 'login.features.packing': 'Listas de equipaje',
+ 'login.features.packingDesc': 'Categorías, progreso y sugerencias',
+ 'login.features.bookings': 'Reservas',
+ 'login.features.bookingsDesc': 'Vuelos, hoteles, restaurantes y más',
+ 'login.features.files': 'Documentos',
+ 'login.features.filesDesc': 'Sube y gestiona documentos',
+ 'login.features.routes': 'Rutas inteligentes',
+ 'login.features.routesDesc': 'Optimización automática y exportación a Google Maps',
+ 'login.selfHosted': 'Autoalojado · Código abierto · Tus datos siguen siendo tuyos',
+ 'login.title': 'Iniciar sesión',
+ 'login.subtitle': 'Bienvenido de nuevo',
+ 'login.signingIn': 'Iniciando sesión…',
+ 'login.signIn': 'Entrar',
+ 'login.createAdmin': 'Crear cuenta de administrador',
+ 'login.createAdminHint': 'Configura la primera cuenta administradora de NOMAD.',
+ 'login.createAccount': 'Crear cuenta',
+ 'login.createAccountHint': 'Crea una cuenta nueva.',
+ 'login.creating': 'Creando…',
+ 'login.noAccount': '¿No tienes cuenta?',
+ 'login.hasAccount': '¿Ya tienes cuenta?',
+ 'login.register': 'Registrarse',
+ 'login.emailPlaceholder': 'tu@correo.com',
+ 'login.username': 'Usuario',
+ 'login.oidc.registrationDisabled': 'El registro está desactivado. Contacta con tu administrador.',
+ 'login.oidc.noEmail': 'No se recibió ningún correo del proveedor.',
+ 'login.oidc.tokenFailed': 'La autenticación falló.',
+ 'login.oidc.invalidState': 'Sesión no válida. Inténtalo de nuevo.',
+ 'login.demoFailed': 'Falló el acceso a la demo',
+ 'login.oidcSignIn': 'Entrar con {name}',
+ 'login.demoHint': 'Prueba la demo: no necesitas registrarte',
+
+ // Register
+ 'register.passwordMismatch': 'Las contraseñas no coinciden',
+ 'register.passwordTooShort': 'La contraseña debe tener al menos 6 caracteres',
+ 'register.failed': 'Falló el registro',
+ 'register.getStarted': 'Empezar',
+ 'register.subtitle': 'Crea una cuenta y empieza a planificar tus viajes.',
+ 'register.feature1': 'Planes de viaje ilimitados',
+ 'register.feature2': 'Vista de mapa interactiva',
+ 'register.feature3': 'Gestiona lugares y categorías',
+ 'register.feature4': 'Haz seguimiento de las reservas',
+ 'register.feature5': 'Crea listas de equipaje',
+ 'register.feature6': 'Guarda fotos y archivos',
+ 'register.createAccount': 'Crear cuenta',
+ 'register.startPlanning': 'Empieza a planificar tu viaje',
+ 'register.minChars': 'Mín. 6 caracteres',
+ 'register.confirmPassword': 'Confirmar contraseña',
+ 'register.repeatPassword': 'Repetir contraseña',
+ 'register.registering': 'Registrando...',
+ 'register.register': 'Registrarse',
+ 'register.hasAccount': '¿Ya tienes cuenta?',
+ 'register.signIn': 'Iniciar sesión',
+
+ // Admin
+ 'admin.title': 'Administración',
+ 'admin.subtitle': 'Gestión de usuarios y ajustes del sistema',
+ 'admin.tabs.users': 'Usuarios',
+ 'admin.tabs.categories': 'Categorías',
+ 'admin.tabs.backup': 'Copia de seguridad',
+ 'admin.stats.users': 'Usuarios',
+ 'admin.stats.trips': 'Viajes',
+ 'admin.stats.places': 'Lugares',
+ 'admin.stats.photos': 'Fotos',
+ 'admin.stats.files': 'Archivos',
+ 'admin.table.user': 'Usuario',
+ 'admin.table.email': 'Correo',
+ 'admin.table.role': 'Rol',
+ 'admin.table.created': 'Creado',
+ 'admin.table.lastLogin': 'Último acceso',
+ 'admin.table.actions': 'Acciones',
+ 'admin.you': '(Tú)',
+ 'admin.editUser': 'Editar usuario',
+ 'admin.newPassword': 'Nueva contraseña',
+ 'admin.newPasswordHint': 'Déjalo vacío para mantener la contraseña actual',
+ 'admin.deleteUser': '¿Eliminar al usuario "{name}"? Todos sus viajes se borrarán permanentemente.',
+ 'admin.deleteUserTitle': 'Eliminar usuario',
+ 'admin.newPasswordPlaceholder': 'Introduce una nueva contraseña…',
+ 'admin.toast.loadError': 'No se pudieron cargar los datos de administración',
+ 'admin.toast.userUpdated': 'Usuario actualizado',
+ 'admin.toast.updateError': 'No se pudo actualizar',
+ 'admin.toast.userDeleted': 'Usuario eliminado',
+ 'admin.toast.deleteError': 'No se pudo eliminar',
+ 'admin.toast.cannotDeleteSelf': 'No puedes eliminar tu propia cuenta',
+ 'admin.toast.userCreated': 'Usuario creado',
+ 'admin.toast.createError': 'No se pudo crear el usuario',
+ 'admin.toast.fieldsRequired': 'Usuario, correo y contraseña son obligatorios',
+ 'admin.createUser': 'Crear usuario',
+ 'admin.tabs.settings': 'Ajustes',
+ 'admin.allowRegistration': 'Permitir el registro',
+ 'admin.allowRegistrationHint': 'Los nuevos usuarios pueden registrarse por sí mismos',
+ 'admin.apiKeys': 'Claves API',
+ 'admin.apiKeysHint': 'Opcional. Activa datos ampliados de lugares, como fotos y previsión del tiempo.',
+ 'admin.mapsKey': 'Clave API de Google Maps',
+ 'admin.mapsKeyHint': 'Obligatoria para buscar lugares. Consíguela en console.cloud.google.com',
+ 'admin.mapsKeyHintLong': 'Sin una clave API, la búsqueda de lugares usa OpenStreetMap. Con una clave de Google también se pueden cargar fotos, valoraciones y horarios de apertura. Consíguela en console.cloud.google.com.',
+ 'admin.recommended': 'Recomendado',
+ 'admin.weatherKey': 'Clave API de OpenWeatherMap',
+ 'admin.weatherKeyHint': 'Para datos meteorológicos. Gratis en openweathermap.org',
+ 'admin.validateKey': 'Probar',
+ 'admin.keyValid': 'Conectado',
+ 'admin.keyInvalid': 'No válida',
+ 'admin.keySaved': 'Claves API guardadas',
+ 'admin.oidcTitle': 'Inicio de sesión único (OIDC)',
+ 'admin.oidcSubtitle': 'Permite iniciar sesión mediante proveedores externos como Google, Apple, Authentik o Keycloak.',
+ 'admin.oidcDisplayName': 'Nombre visible',
+ 'admin.oidcIssuer': 'URL del emisor',
+ 'admin.oidcIssuerHint': 'La URL Issuer de OpenID Connect del proveedor. Ej.: https://accounts.google.com',
+ 'admin.oidcSaved': 'Configuración OIDC guardada',
+
+ // File Types
+ 'admin.fileTypes': 'Tipos de archivo permitidos',
+ 'admin.fileTypesHint': 'Configura qué tipos de archivo pueden subir los usuarios.',
+ 'admin.fileTypesFormat': 'Extensiones separadas por comas (p. ej. jpg,png,pdf,doc). Usa * para permitir todos los tipos.',
+ 'admin.fileTypesSaved': 'Ajustes de tipos de archivo guardados',
+
+ // Addons
+ 'admin.tabs.addons': 'Complementos',
+ 'admin.addons.title': 'Complementos',
+ 'admin.addons.subtitle': 'Activa o desactiva funciones para personalizar tu experiencia en NOMAD.',
+ 'admin.addons.subtitleBefore': 'Activa o desactiva funciones para personalizar tu experiencia en ',
+ 'admin.addons.subtitleAfter': '.',
+ 'admin.addons.enabled': 'Activo',
+ 'admin.addons.disabled': 'Desactivado',
+ 'admin.addons.type.trip': 'Viaje',
+ 'admin.addons.type.global': 'Global',
+ 'admin.addons.tripHint': 'Disponible como pestaña dentro de cada viaje',
+ 'admin.addons.globalHint': 'Disponible como sección independiente en la navegación principal',
+ 'admin.addons.toast.updated': 'Complemento actualizado',
+ 'admin.addons.toast.error': 'No se pudo actualizar el complemento',
+ 'admin.addons.noAddons': 'No hay complementos disponibles',
+ 'admin.weather.title': 'Datos meteorológicos',
+ 'admin.weather.badge': 'Desde el 24 de marzo de 2026',
+ 'admin.weather.description': 'NOMAD utiliza Open-Meteo como fuente de datos meteorológicos. Open-Meteo es un servicio meteorológico gratuito y de código abierto: no requiere clave API.',
+ 'admin.weather.forecast': 'Pronóstico de 16 días',
+ 'admin.weather.forecastDesc': 'Antes eran 5 días (OpenWeatherMap)',
+ 'admin.weather.climate': 'Datos climáticos históricos',
+ 'admin.weather.climateDesc': 'Promedios de los últimos 85 años para fechas posteriores al pronóstico de 16 días',
+ 'admin.weather.requests': '10.000 solicitudes / día',
+ 'admin.weather.requestsDesc': 'Gratis, sin necesidad de clave API',
+ 'admin.weather.locationHint': 'El tiempo se basa en el primer lugar con coordenadas de cada día. Si no hay ningún lugar asignado a un día, se usa como referencia cualquier lugar de la lista.',
+
+ // GitHub
+ 'admin.tabs.github': 'GitHub',
+ 'admin.github.title': 'Historial de versiones',
+ 'admin.github.subtitle': 'Últimas novedades de {repo}',
+ 'admin.github.latest': 'Última',
+ 'admin.github.prerelease': 'Prelanzamiento',
+ 'admin.github.showDetails': 'Mostrar detalles',
+ 'admin.github.hideDetails': 'Ocultar detalles',
+ 'admin.github.loadMore': 'Cargar más',
+ 'admin.github.loading': 'Cargando...',
+ 'admin.github.error': 'No se pudieron cargar las versiones',
+ 'admin.github.by': 'por',
+ 'admin.update.available': 'Actualización disponible',
+ 'admin.update.text': 'NOMAD {version} está disponible. Estás usando {current}.',
+ 'admin.update.button': 'Ver en GitHub',
+ 'admin.update.install': 'Instalar actualización',
+ 'admin.update.confirmTitle': '¿Instalar actualización?',
+ 'admin.update.confirmText': 'NOMAD se actualizará de {current} a {version}. Después, el servidor se reiniciará automáticamente.',
+ 'admin.update.dataInfo': 'Todos tus datos (viajes, usuarios, claves API, subidas, Vacay, Atlas, presupuestos) se conservarán.',
+ 'admin.update.warning': 'La app estará brevemente no disponible durante el reinicio.',
+ 'admin.update.confirm': 'Actualizar ahora',
+ 'admin.update.installing': 'Actualizando…',
+ 'admin.update.success': '¡Actualización instalada! El servidor se está reiniciando…',
+ 'admin.update.failed': 'La actualización falló',
+ 'admin.update.backupHint': 'Recomendamos crear una copia de seguridad antes de actualizar.',
+ 'admin.update.backupLink': 'Ir a Copia de seguridad',
+ 'admin.update.howTo': 'Cómo actualizar',
+ 'admin.update.dockerText': 'Tu instancia de NOMAD se ejecuta en Docker. Para actualizar a {version}, ejecuta los siguientes comandos en tu servidor:',
+ 'admin.update.reloadHint': 'Recarga la página en unos segundos.',
+
+ // Vacay addon
+ 'vacay.subtitle': 'Planifica y gestiona días de vacaciones',
+ 'vacay.settings': 'Ajustes',
+ 'vacay.year': 'Año',
+ 'vacay.addYear': 'Añadir año',
+ 'vacay.removeYear': 'Eliminar año',
+ 'vacay.removeYearConfirm': '¿Eliminar {year}?',
+ 'vacay.removeYearHint': 'Todas las vacaciones y festivos de empresa de este año se borrarán permanentemente.',
+ 'vacay.remove': 'Eliminar',
+ 'vacay.persons': 'Personas',
+ 'vacay.noPersons': 'No se han añadido personas',
+ 'vacay.addPerson': 'Añadir persona',
+ 'vacay.editPerson': 'Editar persona',
+ 'vacay.removePerson': 'Eliminar persona',
+ 'vacay.removePersonConfirm': '¿Eliminar a {name}?',
+ 'vacay.removePersonHint': 'Todas las vacaciones de esta persona se borrarán permanentemente.',
+ 'vacay.personName': 'Nombre',
+ 'vacay.personNamePlaceholder': 'Introduce un nombre',
+ 'vacay.color': 'Color',
+ 'vacay.add': 'Añadir',
+ 'vacay.legend': 'Leyenda',
+ 'vacay.publicHoliday': 'Festivo',
+ 'vacay.companyHoliday': 'Festivo de empresa',
+ 'vacay.weekend': 'Fin de semana',
+ 'vacay.modeVacation': 'Vacaciones',
+ 'vacay.modeCompany': 'Festivo de empresa',
+ 'vacay.entitlement': 'Derecho',
+ 'vacay.entitlementDays': 'Días',
+ 'vacay.used': 'Usados',
+ 'vacay.remaining': 'Restantes',
+ 'vacay.carriedOver': 'de {year}',
+ 'vacay.blockWeekends': 'Bloquear fines de semana',
+ 'vacay.blockWeekendsHint': 'Impide marcar vacaciones en sábados y domingos',
+ 'vacay.publicHolidays': 'Festivos',
+ 'vacay.publicHolidaysHint': 'Marcar festivos en el calendario',
+ 'vacay.selectCountry': 'Seleccionar país',
+ 'vacay.selectRegion': 'Seleccionar región (opcional)',
+ 'vacay.companyHolidays': 'Festivos de empresa',
+ 'vacay.companyHolidaysHint': 'Permitir marcar días festivos comunes de la empresa',
+ 'vacay.companyHolidaysNoDeduct': 'Los festivos de empresa no descuentan días de vacaciones.',
+ 'vacay.carryOver': 'Arrastrar saldo',
+ 'vacay.carryOverHint': 'Trasladar automáticamente los días restantes al año siguiente',
+ 'vacay.sharing': 'Compartir',
+ 'vacay.sharingHint': 'Comparte tu calendario de vacaciones con otros usuarios de NOMAD',
+ 'vacay.owner': 'Propietario',
+ 'vacay.shareEmailPlaceholder': 'Correo electrónico del usuario de NOMAD',
+ 'vacay.shareSuccess': 'Plan compartido correctamente',
+ 'vacay.shareError': 'No se pudo compartir el plan',
+ 'vacay.dissolve': 'Deshacer fusión',
+ 'vacay.dissolveHint': 'Separar de nuevo los calendarios. Tus entradas se conservarán.',
+ 'vacay.dissolveAction': 'Disolver',
+ 'vacay.dissolved': 'Calendario separado',
+ 'vacay.fusedWith': 'Fusionado con',
+ 'vacay.you': 'tú',
+ 'vacay.noData': 'Sin datos',
+ 'vacay.changeColor': 'Cambiar color',
+ 'vacay.inviteUser': 'Invitar usuario',
+ 'vacay.inviteHint': 'Invita a otro usuario de NOMAD a compartir un calendario combinado de vacaciones.',
+ 'vacay.selectUser': 'Seleccionar usuario',
+ 'vacay.sendInvite': 'Enviar invitación',
+ 'vacay.inviteSent': 'Invitación enviada',
+ 'vacay.inviteError': 'No se pudo enviar la invitación',
+ 'vacay.pending': 'pendiente',
+ 'vacay.noUsersAvailable': 'No hay usuarios disponibles',
+ 'vacay.accept': 'Aceptar',
+ 'vacay.decline': 'Rechazar',
+ 'vacay.acceptFusion': 'Aceptar y fusionar',
+ 'vacay.inviteTitle': 'Solicitud de fusión',
+ 'vacay.inviteWantsToFuse': 'quiere compartir un calendario de vacaciones contigo.',
+ 'vacay.fuseInfo1': 'Ambos veréis todas las entradas de vacaciones en un único calendario compartido.',
+ 'vacay.fuseInfo2': 'Ambas partes pueden crear y editar entradas mutuamente.',
+ 'vacay.fuseInfo3': 'Ambas partes pueden borrar entradas y cambiar el número de días de vacaciones disponibles.',
+ 'vacay.fuseInfo4': 'Ajustes como festivos y festivos de empresa se comparten.',
+ 'vacay.fuseInfo5': 'La fusión puede disolverse en cualquier momento por cualquiera de las partes. Tus entradas se conservarán.',
+
+ // Atlas addon
+ 'atlas.subtitle': 'Tu huella viajera por el mundo',
+ 'atlas.countries': 'Países',
+ 'atlas.trips': 'Viajes',
+ 'atlas.places': 'Lugares',
+ 'atlas.days': 'Días',
+ 'atlas.visitedCountries': 'Países visitados',
+ 'atlas.cities': 'Ciudades',
+ 'atlas.noData': 'Aún no hay datos de viaje',
+ 'atlas.noDataHint': 'Crea un viaje y añade lugares para ver tu mapa del mundo',
+ 'atlas.lastTrip': 'Último viaje',
+ 'atlas.nextTrip': 'Próximo viaje',
+ 'atlas.daysLeft': 'días restantes',
+ 'atlas.streak': 'Racha',
+ 'atlas.year': 'año',
+ 'atlas.years': 'años',
+ 'atlas.yearInRow': 'año seguido',
+ 'atlas.yearsInRow': 'años seguidos',
+ 'atlas.tripIn': 'viaje en',
+ 'atlas.tripsIn': 'viajes en',
+ 'atlas.since': 'desde',
+ 'atlas.europe': 'Europa',
+ 'atlas.asia': 'Asia',
+ 'atlas.northAmerica': 'América del Norte',
+ 'atlas.southAmerica': 'América del Sur',
+ 'atlas.africa': 'África',
+ 'atlas.oceania': 'Oceanía',
+ 'atlas.other': 'Otros',
+ 'atlas.firstVisit': 'Primer viaje',
+ 'atlas.lastVisitLabel': 'Último viaje',
+ 'atlas.tripSingular': 'Viaje',
+ 'atlas.tripPlural': 'Viajes',
+ 'atlas.placeVisited': 'Lugar visitado',
+ 'atlas.placesVisited': 'Lugares visitados',
+
+ // Trip Planner
+ 'trip.tabs.plan': 'Plan',
+ 'trip.tabs.reservations': 'Reservas',
+ 'trip.tabs.reservationsShort': 'Reservas',
+ 'trip.tabs.packing': 'Lista de equipaje',
+ 'trip.tabs.packingShort': 'Equipaje',
+ 'trip.tabs.budget': 'Presupuesto',
+ 'trip.tabs.memories': 'Recuerdos',
+ 'trip.tabs.files': 'Archivos',
+ 'trip.loading': 'Cargando viaje...',
+ 'trip.mobilePlan': 'Plan',
+ 'trip.mobilePlaces': 'Lugares',
+ 'trip.toast.placeUpdated': 'Lugar actualizado',
+ 'trip.toast.placeAdded': 'Lugar añadido',
+ 'trip.toast.placeDeleted': 'Lugar eliminado',
+ 'trip.toast.selectDay': 'Selecciona primero un día',
+ 'trip.toast.assignedToDay': 'Lugar asignado al día',
+ 'trip.toast.reorderError': 'No se pudo reordenar',
+ 'trip.toast.reservationUpdated': 'Reserva actualizada',
+ 'trip.toast.reservationAdded': 'Reserva añadida',
+ 'trip.toast.deleted': 'Eliminado',
+ 'trip.confirm.deletePlace': '¿Seguro que quieres eliminar este lugar?',
+
+ // Day Plan Sidebar
+ 'dayplan.emptyDay': 'No hay lugares planificados para este día',
+ 'dayplan.addNote': 'Añadir nota',
+ 'dayplan.editNote': 'Editar nota',
+ 'dayplan.noteAdd': 'Añadir nota',
+ 'dayplan.noteEdit': 'Editar nota',
+ 'dayplan.noteTitle': 'Nota',
+ 'dayplan.noteSubtitle': 'Nota diaria',
+ 'dayplan.totalCost': 'Coste total',
+ 'dayplan.days': 'Días',
+ 'dayplan.dayN': 'Día {n}',
+ 'dayplan.calculating': 'Calculando...',
+ 'dayplan.route': 'Ruta',
+ 'dayplan.optimize': 'Optimizar',
+ 'dayplan.optimized': 'Ruta optimizada',
+ 'dayplan.routeError': 'No se pudo calcular la ruta',
+ 'dayplan.toast.needTwoPlaces': 'Se necesitan al menos dos lugares para optimizar la ruta',
+ 'dayplan.toast.routeOptimized': 'Ruta optimizada',
+ 'dayplan.toast.noGeoPlaces': 'No se encontraron lugares con coordenadas para calcular la ruta',
+ 'dayplan.confirmed': 'Confirmado',
+ 'dayplan.pendingRes': 'Pendiente',
+ 'dayplan.pdf': 'PDF',
+ 'dayplan.pdfTooltip': 'Exportar plan diario como PDF',
+ 'dayplan.pdfError': 'No se pudo exportar el PDF',
+
+ // Places Sidebar
+ 'places.addPlace': 'Añadir lugar/actividad',
+ 'places.assignToDay': '¿A qué día añadirlo?',
+ 'places.all': 'Todo',
+ 'places.unplanned': 'Sin planificar',
+ 'places.search': 'Buscar lugares...',
+ 'places.allCategories': 'Todas las categorías',
+ 'places.count': '{count} lugares',
+ 'places.countSingular': '1 lugar',
+ 'places.allPlanned': 'Todos los lugares están planificados',
+ 'places.noneFound': 'No se encontraron lugares',
+ 'places.editPlace': 'Editar lugar',
+ 'places.formName': 'Nombre',
+ 'places.formNamePlaceholder': 'p. ej. Torre Eiffel',
+ 'places.formDescription': 'Descripción',
+ 'places.formDescriptionPlaceholder': 'Descripción breve...',
+ 'places.formAddress': 'Dirección',
+ 'places.formAddressPlaceholder': 'Calle, ciudad, país',
+ 'places.formLat': 'Latitud (p. ej. 48.8566)',
+ 'places.formLng': 'Longitud (p. ej. 2.3522)',
+ 'places.formCategory': 'Categoría',
+ 'places.noCategory': 'Sin categoría',
+ 'places.categoryNamePlaceholder': 'Nombre de la categoría',
+ 'places.formTime': 'Hora',
+ 'places.startTime': 'Inicio',
+ 'places.endTime': 'Fin',
+ 'places.endTimeBeforeStart': 'La hora de fin es anterior a la de inicio',
+ 'places.timeCollision': 'Solapamiento horario con:',
+ 'places.formWebsite': 'Página web',
+ 'places.formNotesPlaceholder': 'Notas personales...',
+ 'places.formReservation': 'Reserva',
+ 'places.reservationNotesPlaceholder': 'Notas de reserva, número de confirmación...',
+ 'places.mapsSearchPlaceholder': 'Buscar lugares...',
+ 'places.mapsSearchError': 'La búsqueda de lugares falló.',
+ 'places.osmHint': 'Usando búsqueda con OpenStreetMap (sin fotos, horarios ni valoraciones). Añade una clave API de Google en Ajustes para obtener todos los detalles.',
+ 'places.osmActive': 'Búsqueda mediante OpenStreetMap (sin fotos, valoraciones ni horarios). Añade una clave API de Google en Ajustes para datos ampliados.',
+ 'places.categoryCreateError': 'No se pudo crear la categoría',
+ 'places.nameRequired': 'Introduce un nombre',
+ 'places.saveError': 'No se pudo guardar',
+
+ // Place Inspector
+ 'inspector.opened': 'Abierto',
+ 'inspector.closed': 'Cerrado',
+ 'inspector.openingHours': 'Horario de apertura',
+ 'inspector.showHours': 'Mostrar horario',
+ 'inspector.files': 'Archivos',
+ 'inspector.filesCount': '{count} archivos',
+ 'inspector.removeFromDay': 'Quitar del día',
+ 'inspector.addToDay': 'Añadir al día',
+ 'inspector.confirmedRes': 'Reserva confirmada',
+ 'inspector.pendingRes': 'Reserva pendiente',
+ 'inspector.google': 'Abrir en Google Maps',
+ 'inspector.website': 'Abrir la web',
+ 'inspector.addRes': 'Reserva',
+ 'inspector.editRes': 'Editar reserva',
+ 'inspector.participants': 'Participantes',
+
+ // Reservations
+ 'reservations.title': 'Reservas',
+ 'reservations.empty': 'Aún no hay reservas',
+ 'reservations.emptyHint': 'Añade reservas de vuelos, hoteles y más',
+ 'reservations.add': 'Añadir reserva',
+ 'reservations.addManual': 'Reserva manual',
+ 'reservations.placeHint': 'Consejo: es mejor crear las reservas directamente desde un lugar para vincularlas con el plan del día.',
+ 'reservations.confirmed': 'Confirmada',
+ 'reservations.pending': 'Pendiente',
+ 'reservations.summary': '{confirmed} confirmadas, {pending} pendientes',
+ 'reservations.fromPlan': 'Del plan',
+ 'reservations.showFiles': 'Mostrar archivos',
+ 'reservations.editTitle': 'Editar reserva',
+ 'reservations.status': 'Estado',
+ 'reservations.datetime': 'Fecha y hora',
+ 'reservations.startTime': 'Hora de inicio',
+ 'reservations.endTime': 'Hora de fin',
+ 'reservations.date': 'Fecha',
+ 'reservations.time': 'Hora',
+ 'reservations.timeAlt': 'Hora (alternativa, p. ej. 19:30)',
+ 'reservations.notes': 'Notas',
+ 'reservations.notesPlaceholder': 'Notas adicionales...',
+ 'reservations.type.flight': 'Vuelo',
+ 'reservations.type.hotel': 'Hotel',
+ 'reservations.type.restaurant': 'Restaurante',
+ 'reservations.type.train': 'Tren',
+ 'reservations.type.car': 'Coche de alquiler',
+ 'reservations.type.cruise': 'Crucero',
+ 'reservations.type.event': 'Evento',
+ 'reservations.type.tour': 'Tour',
+ 'reservations.type.other': 'Otro',
+ 'reservations.confirm.delete': '¿Seguro que quieres eliminar la reserva "{name}"?',
+ 'reservations.toast.updated': 'Reserva actualizada',
+ 'reservations.toast.removed': 'Reserva eliminada',
+ 'reservations.toast.fileUploaded': 'Archivo subido',
+ 'reservations.toast.uploadError': 'No se pudo subir',
+ 'reservations.newTitle': 'Nueva reserva',
+ 'reservations.bookingType': 'Tipo de reserva',
+ 'reservations.titleLabel': 'Título',
+ 'reservations.titlePlaceholder': 'p. ej. Lufthansa LH123, Hotel Adlon, ...',
+ 'reservations.locationAddress': 'Ubicación / dirección',
+ 'reservations.locationPlaceholder': 'Dirección, aeropuerto, hotel...',
+ 'reservations.confirmationCode': 'Código de reserva',
+ 'reservations.confirmationPlaceholder': 'p. ej. ABC12345',
+ 'reservations.day': 'Día',
+ 'reservations.noDay': 'Sin día',
+ 'reservations.place': 'Lugar',
+ 'reservations.noPlace': 'Sin lugar',
+ 'reservations.pendingSave': 'se guardará…',
+ 'reservations.uploading': 'Subiendo...',
+ 'reservations.attachFile': 'Adjuntar archivo',
+ 'reservations.toast.saveError': 'No se pudo guardar',
+ 'reservations.toast.updateError': 'No se pudo actualizar',
+ 'reservations.toast.deleteError': 'No se pudo eliminar',
+ 'reservations.confirm.remove': '¿Eliminar la reserva de "{name}"?',
+ 'reservations.linkAssignment': 'Vincular a una asignación del día',
+ 'reservations.pickAssignment': 'Selecciona una asignación de tu plan...',
+ 'reservations.noAssignment': 'Sin vínculo (independiente)',
+
+ // Budget
+ 'budget.title': 'Presupuesto',
+ 'budget.emptyTitle': 'Aún no se ha creado ningún presupuesto',
+ 'budget.emptyText': 'Crea categorías y entradas para planificar el presupuesto de tu viaje',
+ 'budget.emptyPlaceholder': 'Introduce el nombre de la categoría...',
+ 'budget.createCategory': 'Crear categoría',
+ 'budget.category': 'Categoría',
+ 'budget.categoryName': 'Nombre de la categoría',
+ 'budget.table.name': 'Nombre',
+ 'budget.table.total': 'Total',
+ 'budget.table.persons': 'Personas',
+ 'budget.table.days': 'Días',
+ 'budget.table.perPerson': 'Por persona',
+ 'budget.table.perDay': 'Por día',
+ 'budget.table.perPersonDay': 'Por pers. / día',
+ 'budget.table.note': 'Nota',
+ 'budget.newEntry': 'Nueva entrada',
+ 'budget.defaultEntry': 'Nueva entrada',
+ 'budget.defaultCategory': 'Nueva categoría',
+ 'budget.total': 'Total',
+ 'budget.totalBudget': 'Presupuesto total',
+ 'budget.byCategory': 'Por categoría',
+ 'budget.editTooltip': 'Haz clic para editar',
+ 'budget.confirm.deleteCategory': '¿Seguro que quieres eliminar la categoría "{name}" con {count} entradas?',
+ 'budget.deleteCategory': 'Eliminar categoría',
+ 'budget.perPerson': 'Por persona',
+ 'budget.paid': 'Pagado',
+ 'budget.open': 'Abrir',
+ 'budget.noMembers': 'No hay miembros asignados',
+
+ // Files
+ 'files.title': 'Archivos',
+ 'files.count': '{count} archivos',
+ 'files.countSingular': '1 archivo',
+ 'files.uploaded': '{count} archivos subidos',
+ 'files.uploadError': 'La subida falló',
+ 'files.dropzone': 'Arrastra aquí los archivos',
+ 'files.dropzoneHint': 'o haz clic para explorar',
+ 'files.allowedTypes': 'Imágenes, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Máx. 50 MB',
+ 'files.uploading': 'Subiendo...',
+ 'files.filterAll': 'Todo',
+ 'files.filterPdf': 'PDF',
+ 'files.filterImages': 'Imágenes',
+ 'files.filterDocs': 'Documentos',
+ 'files.filterCollab': 'Notas de colaboración',
+ 'files.sourceCollab': 'Desde notas de colaboración',
+ 'files.empty': 'Aún no hay archivos',
+ 'files.emptyHint': 'Sube archivos para adjuntarlos a tu viaje',
+ 'files.openTab': 'Abrir en una pestaña nueva',
+ 'files.confirm.delete': '¿Seguro que quieres eliminar este archivo?',
+ 'files.toast.deleted': 'Archivo eliminado',
+ 'files.toast.deleteError': 'No se pudo eliminar el archivo',
+ 'files.sourcePlan': 'Plan diario',
+ 'files.sourceBooking': 'Reserva',
+ 'files.attach': 'Adjuntar',
+ 'files.pasteHint': 'También puedes pegar imágenes desde el portapapeles (Ctrl+V)',
+
+ // Packing
+ 'packing.title': 'Lista de equipaje',
+ 'packing.empty': 'La lista de equipaje está vacía',
+ 'packing.progress': '{packed} de {total} preparados ({percent}%)',
+ 'packing.clearChecked': 'Eliminar {count} marcados',
+ 'packing.clearCheckedShort': 'Eliminar {count}',
+ 'packing.suggestions': 'Sugerencias',
+ 'packing.suggestionsTitle': 'Añadir sugerencias',
+ 'packing.allSuggested': 'Todas las sugerencias añadidas',
+ 'packing.allPacked': '¡Todo preparado!',
+ 'packing.addPlaceholder': 'Añadir nuevo elemento...',
+ 'packing.categoryPlaceholder': 'Categoría...',
+ 'packing.filterAll': 'Todo',
+ 'packing.filterOpen': 'Pendientes',
+ 'packing.filterDone': 'Hecho',
+ 'packing.emptyTitle': 'La lista de equipaje está vacía',
+ 'packing.emptyHint': 'Añade elementos o usa las sugerencias',
+ 'packing.emptyFiltered': 'Ningún elemento coincide con este filtro',
+ 'packing.menuRename': 'Renombrar',
+ 'packing.menuCheckAll': 'Marcar todo',
+ 'packing.menuUncheckAll': 'Desmarcar todo',
+ 'packing.menuDeleteCat': 'Eliminar categoría',
+ 'packing.changeCategory': 'Cambiar categoría',
+ 'packing.confirm.clearChecked': '¿Seguro que quieres eliminar {count} elementos marcados?',
+ 'packing.confirm.deleteCat': '¿Seguro que quieres eliminar la categoría "{name}" con {count} elementos?',
+ 'packing.defaultCategory': 'Otros',
+ 'packing.toast.saveError': 'No se pudo guardar',
+ 'packing.toast.deleteError': 'No se pudo eliminar',
+ 'packing.toast.renameError': 'No se pudo renombrar',
+ 'packing.toast.addError': 'No se pudo añadir',
+
+ // Packing suggestions
+ 'packing.suggestions.items': [
+ { name: 'Pasaporte', category: 'Documentos' },
+ { name: 'Documento de identidad', category: 'Documentos' },
+ { name: 'Seguro de viaje', category: 'Documentos' },
+ { name: 'Billetes de vuelo', category: 'Documentos' },
+ { name: 'Tarjeta de crédito', category: 'Finanzas' },
+ { name: 'Efectivo', category: 'Finanzas' },
+ { name: 'Visado', category: 'Documentos' },
+ { name: 'Camisetas', category: 'Ropa' },
+ { name: 'Pantalones', category: 'Ropa' },
+ { name: 'Ropa interior', category: 'Ropa' },
+ { name: 'Calcetines', category: 'Ropa' },
+ { name: 'Chaqueta', category: 'Ropa' },
+ { name: 'Pijama', category: 'Ropa' },
+ { name: 'Ropa de baño', category: 'Ropa' },
+ { name: 'Impermeable', category: 'Ropa' },
+ { name: 'Zapatos cómodos', category: 'Ropa' },
+ { name: 'Cepillo de dientes', category: 'Aseo' },
+ { name: 'Pasta de dientes', category: 'Aseo' },
+ { name: 'Champú', category: 'Aseo' },
+ { name: 'Desodorante', category: 'Aseo' },
+ { name: 'Protector solar', category: 'Aseo' },
+ { name: 'Maquinilla de afeitar', category: 'Aseo' },
+ { name: 'Cargador', category: 'Electrónica' },
+ { name: 'Batería externa', category: 'Electrónica' },
+ { name: 'Auriculares', category: 'Electrónica' },
+ { name: 'Adaptador de viaje', category: 'Electrónica' },
+ { name: 'Cámara', category: 'Electrónica' },
+ { name: 'Analgésicos', category: 'Salud' },
+ { name: 'Tiritas', category: 'Salud' },
+ { name: 'Desinfectante', category: 'Salud' },
+ ],
+
+ // Members / Sharing
+ 'members.shareTrip': 'Compartir viaje',
+ 'members.inviteUser': 'Invitar usuario',
+ 'members.selectUser': 'Seleccionar usuario…',
+ 'members.invite': 'Invitar',
+ 'members.allHaveAccess': 'Todos los usuarios ya tienen acceso.',
+ 'members.access': 'Acceso',
+ 'members.person': 'persona',
+ 'members.persons': 'personas',
+ 'members.you': 'tú',
+ 'members.owner': 'Propietario',
+ 'members.leaveTrip': 'Abandonar viaje',
+ 'members.removeAccess': 'Quitar acceso',
+ 'members.confirmLeave': '¿Abandonar el viaje? Perderás el acceso.',
+ 'members.confirmRemove': '¿Quitar el acceso de este usuario?',
+ 'members.loadError': 'No se pudieron cargar los miembros',
+ 'members.added': 'añadido',
+ 'members.addError': 'No se pudo añadir',
+ 'members.removed': 'Miembro eliminado',
+ 'members.removeError': 'No se pudo eliminar',
+
+ // Categories (Admin)
+ 'categories.title': 'Categorías',
+ 'categories.subtitle': 'Gestiona categorías para lugares',
+ 'categories.new': 'Nueva categoría',
+ 'categories.empty': 'Aún no hay categorías',
+ 'categories.namePlaceholder': 'Nombre de la categoría',
+ 'categories.icon': 'Icono',
+ 'categories.color': 'Color',
+ 'categories.customColor': 'Elegir color personalizado',
+ 'categories.preview': 'Vista previa',
+ 'categories.defaultName': 'Categoría',
+ 'categories.update': 'Actualizar',
+ 'categories.create': 'Crear',
+ 'categories.confirm.delete': '¿Eliminar la categoría? Los lugares de esta categoría no se eliminarán.',
+ 'categories.toast.loadError': 'No se pudieron cargar las categorías',
+ 'categories.toast.nameRequired': 'Introduce un nombre',
+ 'categories.toast.updated': 'Categoría actualizada',
+ 'categories.toast.created': 'Categoría creada',
+ 'categories.toast.saveError': 'No se pudo guardar',
+ 'categories.toast.deleted': 'Categoría eliminada',
+ 'categories.toast.deleteError': 'No se pudo eliminar',
+
+ // Backup (Admin)
+ 'backup.title': 'Copia de seguridad de datos',
+ 'backup.subtitle': 'Base de datos y todos los archivos subidos',
+ 'backup.refresh': 'Actualizar',
+ 'backup.upload': 'Subir copia de seguridad',
+ 'backup.uploading': 'Subiendo…',
+ 'backup.create': 'Crear copia',
+ 'backup.creating': 'Creando…',
+ 'backup.empty': 'Aún no hay copias',
+ 'backup.createFirst': 'Crear la primera copia',
+ 'backup.download': 'Descargar',
+ 'backup.restore': 'Restaurar',
+ 'backup.confirm.restore': '¿Restaurar la copia "{name}"?\n\nTodos los datos actuales serán reemplazados por la copia.',
+ 'backup.confirm.uploadRestore': '¿Subir y restaurar el archivo de copia "{name}"?\n\nTodos los datos actuales se sobrescribirán.',
+ 'backup.confirm.delete': '¿Eliminar la copia "{name}"?',
+ 'backup.toast.loadError': 'No se pudieron cargar las copias',
+ 'backup.toast.created': 'Copia de seguridad creada correctamente',
+ 'backup.toast.createError': 'No se pudo crear la copia',
+ 'backup.toast.restored': 'Copia restaurada. La página se recargará…',
+ 'backup.toast.restoreError': 'No se pudo restaurar',
+ 'backup.toast.uploadError': 'No se pudo subir',
+ 'backup.toast.deleted': 'Copia eliminada',
+ 'backup.toast.deleteError': 'No se pudo eliminar',
+ 'backup.toast.downloadError': 'La descarga falló',
+ 'backup.toast.settingsSaved': 'Ajustes de copia automática guardados',
+ 'backup.toast.settingsError': 'No se pudieron guardar los ajustes',
+ 'backup.auto.title': 'Copia automática',
+ 'backup.auto.subtitle': 'Copia de seguridad automática según una programación',
+ 'backup.auto.enable': 'Activar copia automática',
+ 'backup.auto.enableHint': 'Se crearán copias automáticamente según la frecuencia elegida',
+ 'backup.auto.interval': 'Intervalo',
+ 'backup.auto.keepLabel': 'Eliminar copias antiguas después de',
+ 'backup.interval.hourly': 'Cada hora',
+ 'backup.interval.daily': 'Diaria',
+ 'backup.interval.weekly': 'Semanal',
+ 'backup.interval.monthly': 'Mensual',
+ 'backup.keep.1day': '1 día',
+ 'backup.keep.3days': '3 días',
+ 'backup.keep.7days': '7 días',
+ 'backup.keep.14days': '14 días',
+ 'backup.keep.30days': '30 días',
+ 'backup.keep.forever': 'Conservar para siempre',
+
+ // Photos
+ 'photos.allDays': 'Todos los días',
+ 'photos.title': 'Recuerdos',
+ 'photos.noPhotos': 'Aún no hay fotos',
+ 'photos.uploadHint': 'Sube y organiza las fotos compartidas de este viaje',
+ 'photos.clickToSelect': 'o haz clic para seleccionar',
+ 'photos.dropHere': 'Suelta aquí las fotos...',
+ 'photos.dropTitle': 'Suelta aquí las fotos',
+ 'photos.fileHint': 'JPG, PNG, GIF, WebP · máx. 10 MB · hasta 30 fotos',
+ 'photos.selectedCount': '{count} foto(s) seleccionada(s)',
+ 'photos.sharedAlbum': '{count} recuerdos en este álbum compartido',
+ 'photos.sharedAlbumFor': '{count} recuerdos en {trip}',
+ 'photos.allPlaces': 'Todos los lugares',
+ 'photos.view.grid': 'Cuadrícula',
+ 'photos.view.day': 'Por día',
+ 'photos.view.place': 'Por lugar',
+ 'photos.stats.total': 'Fotos',
+ 'photos.stats.days': 'Días',
+ 'photos.stats.places': 'Lugares',
+ 'photos.stats.latest': 'Última subida',
+ 'photos.sectionCount': '{count} foto(s)',
+ 'photos.ungrouped': 'Sin clasificar',
+ 'photos.featured': 'Recuerdo destacado',
+ 'photos.coverFallback': 'Portada del álbum compartido',
+ 'photos.coverHint': 'Una imagen destacada para este álbum de viaje',
+ 'photos.mapTitle': 'Mapa de recuerdos',
+ 'photos.mapHint': 'Explora los lugares vinculados en el mismo mapa que usamos en el plan',
+ 'photos.mapEmpty': 'Vincula tus fotos a lugares para verlas ubicadas en el mapa.',
+ 'photos.linkDay': 'Vincular día',
+ 'photos.noDay': 'Sin día',
+ 'photos.linkPlace': 'Vincular lugar',
+ 'photos.noPlace': 'Sin lugar',
+ 'photos.captionLabel': 'Pie de foto (para todas)',
+ 'photos.captionPlaceholder': 'Pie de foto opcional...',
+ 'photos.addCaption': 'Añadir un pie de foto...',
+ 'photos.uploadN': 'Subida de {n} foto(s)',
+ 'admin.addons.catalog.memories.name': 'Recuerdos',
+ 'admin.addons.catalog.memories.description': 'Álbumes de fotos compartidos para cada viaje',
+ 'admin.addons.catalog.packing.name': 'Equipaje',
+ 'admin.addons.catalog.packing.description': 'Prepara tu equipaje con listas de comprobación para cada viaje',
+ 'admin.addons.catalog.budget.name': 'Presupuesto',
+ 'admin.addons.catalog.budget.description': 'Controla los gastos y planifica el presupuesto del viaje',
+ 'admin.addons.catalog.documents.name': 'Documentos',
+ 'admin.addons.catalog.documents.description': 'Guarda y gestiona la documentación del viaje',
+ 'admin.addons.catalog.vacay.name': 'Vacaciones',
+ 'admin.addons.catalog.vacay.description': 'Planificador personal de vacaciones con vista de calendario',
+ 'admin.addons.catalog.atlas.name': 'Atlas',
+ 'admin.addons.catalog.atlas.description': 'Mapa del mundo con los países visitados y estadísticas de viaje',
+ 'admin.addons.catalog.collab.name': 'Colaboración',
+ 'admin.addons.catalog.collab.description': 'Notas, encuestas y chat en tiempo real para organizar el viaje',
+
+ // Backup restore modal
+ 'backup.restoreConfirmTitle': '¿Restaurar copia?',
+ 'backup.restoreWarning': 'Todos los datos actuales (viajes, lugares, usuarios, subidas) serán reemplazados permanentemente por la copia. Esta acción no se puede deshacer.',
+ 'backup.restoreTip': 'Consejo: crea una copia del estado actual antes de restaurar.',
+ 'backup.restoreConfirm': 'Sí, restaurar',
+
+ // PDF
+ 'pdf.travelPlan': 'Plan de viaje',
+ 'pdf.planned': 'Planificado',
+ 'pdf.costLabel': 'Coste EUR',
+ 'pdf.preview': 'Vista previa PDF',
+ 'pdf.saveAsPdf': 'Guardar como PDF',
+
+ // Planner
+ 'planner.places': 'Lugares',
+ 'planner.bookings': 'Reservas',
+ 'planner.packingList': 'Lista de equipaje',
+ 'planner.documents': 'Documentos',
+ 'planner.dayPlan': 'Plan por días',
+ 'planner.reservations': 'Reservas',
+ 'planner.minTwoPlaces': 'Se necesitan al menos 2 lugares con coordenadas',
+ 'planner.noGeoPlaces': 'No hay lugares con coordenadas disponibles',
+ 'planner.routeCalculated': 'Ruta calculada',
+ 'planner.routeCalcFailed': 'No se pudo calcular la ruta',
+ 'planner.routeError': 'Error al calcular la ruta',
+ 'planner.routeOptimized': 'Ruta optimizada',
+ 'planner.reservationUpdated': 'Reserva actualizada',
+ 'planner.reservationAdded': 'Reserva añadida',
+ 'planner.confirmDeleteReservation': '¿Eliminar reserva?',
+ 'planner.reservationDeleted': 'Reserva eliminada',
+ 'planner.days': 'Días',
+ 'planner.allPlaces': 'Todos los lugares',
+ 'planner.totalPlaces': '{n} lugares en total',
+ 'planner.noDaysPlanned': 'Aún no hay días planificados',
+ 'planner.editTrip': 'Editar viaje →',
+ 'planner.placeOne': '1 lugar',
+ 'planner.placeN': '{n} lugares',
+ 'planner.addNote': 'Añadir nota',
+ 'planner.noEntries': 'No hay entradas para este día',
+ 'planner.addPlace': 'Añadir lugar/actividad',
+ 'planner.addPlaceShort': '+ Añadir lugar/actividad',
+ 'planner.resPending': 'Reserva pendiente · ',
+ 'planner.resConfirmed': 'Reserva confirmada · ',
+ 'planner.notePlaceholder': 'Nota…',
+ 'planner.noteTimePlaceholder': 'Hora (opcional)',
+ 'planner.noteExamplePlaceholder': 'p. ej. S3 a las 14:30 desde la estación central, ferry desde el muelle 7, pausa para comer…',
+ 'planner.totalCost': 'Coste total',
+ 'planner.searchPlaces': 'Buscar lugares…',
+ 'planner.allCategories': 'Todas las categorías',
+ 'planner.noPlacesFound': 'No se encontraron lugares',
+ 'planner.addFirstPlace': 'Añadir el primer lugar',
+ 'planner.noReservations': 'Sin reservas',
+ 'planner.addFirstReservation': 'Añadir la primera reserva',
+ 'planner.new': 'Nuevo',
+ 'planner.addToDay': '+ Día',
+ 'planner.calculating': 'Calculando…',
+ 'planner.route': 'Ruta',
+ 'planner.optimize': 'Optimizar',
+ 'planner.openGoogleMaps': 'Abrir en Google Maps',
+ 'planner.selectDayHint': 'Selecciona un día de la lista izquierda para ver su plan',
+ 'planner.noPlacesForDay': 'Aún no hay lugares para este día',
+ 'planner.addPlacesLink': 'Añadir lugares →',
+ 'planner.minTotal': 'min en total',
+ 'planner.noReservation': 'Sin reserva',
+ 'planner.removeFromDay': 'Quitar del día',
+ 'planner.addToThisDay': 'Añadir al día',
+ 'planner.overview': 'Vista general',
+ 'planner.noDays': 'No hay días todavía',
+ 'planner.editTripToAddDays': 'Edita el viaje para añadir días',
+ 'planner.dayCount': '{n} días',
+ 'planner.clickToUnlock': 'Haz clic para desbloquear',
+ 'planner.keepPosition': 'Mantener posición durante la optimización de ruta',
+ 'planner.dayDetails': 'Detalles del día',
+ 'planner.dayN': 'Día {n}',
+ 'planner.notes': 'Notas',
+ 'planner.addDayNote': 'Añadir notas para este día...',
+
+ // Dashboard Stats
+ 'stats.countries': 'Países',
+ 'stats.cities': 'Ciudades',
+ 'stats.trips': 'Viajes',
+ 'stats.places': 'Lugares',
+ 'stats.worldProgress': 'Progreso mundial',
+ 'stats.visited': 'visitados',
+ 'stats.remaining': 'restantes',
+ 'stats.visitedCountries': 'Países visitados',
+
+ // Day Detail Panel
+ 'day.precipProb': 'Probabilidad de lluvia',
+ 'day.precipitation': 'Precipitación',
+ 'day.wind': 'Viento',
+ 'day.sunrise': 'Amanecer',
+ 'day.sunset': 'Atardecer',
+ 'day.hourlyForecast': 'Pronóstico por horas',
+ 'day.climateHint': 'Promedios históricos: el pronóstico real está disponible dentro de los 16 días previos a la fecha.',
+ 'day.noWeather': 'No hay datos meteorológicos disponibles. Añade un lugar con coordenadas.',
+ 'day.overview': 'Resumen diario',
+ 'day.accommodation': 'Alojamiento',
+ 'day.addAccommodation': 'Añadir alojamiento',
+ 'day.hotelDayRange': 'Aplicar a los días',
+ 'day.noPlacesForHotel': 'Añade primero lugares al viaje',
+ 'day.allDays': 'Todos',
+ 'day.checkIn': 'Check-in',
+ 'day.checkOut': 'Check-out',
+ 'day.confirmation': 'Confirmación',
+ 'day.editAccommodation': 'Editar alojamiento',
+ 'day.reservations': 'Reservas',
+
+ // Collab Addon
+ 'collab.tabs.chat': 'Mensajes',
+ 'collab.tabs.notes': 'Notas',
+ 'collab.tabs.polls': 'Encuestas',
+ 'collab.whatsNext.title': 'Qué viene ahora',
+ 'collab.whatsNext.today': 'Hoy',
+ 'collab.whatsNext.tomorrow': 'Mañana',
+ 'collab.whatsNext.empty': 'No hay actividades próximas',
+ 'collab.whatsNext.until': 'hasta',
+ 'collab.whatsNext.emptyHint': 'Las actividades con hora aparecerán aquí',
+ 'collab.chat.send': 'Enviar',
+ 'collab.chat.placeholder': 'Escribe un mensaje...',
+ 'collab.chat.empty': 'Empieza la conversación',
+ 'collab.chat.emptyHint': 'Los mensajes se comparten con todos los miembros del viaje',
+ 'collab.chat.emptyDesc': 'Comparte ideas, planes y novedades con tu grupo de viaje',
+ 'collab.chat.today': 'Hoy',
+ 'collab.chat.yesterday': 'Ayer',
+ 'collab.chat.deletedMessage': 'eliminó un mensaje',
+ 'collab.chat.loadMore': 'Cargar mensajes anteriores',
+ 'collab.chat.justNow': 'justo ahora',
+ 'collab.chat.minutesAgo': 'hace {n} min',
+ 'collab.chat.hoursAgo': 'hace {n} h',
+ 'collab.notes.title': 'Notas',
+ 'collab.notes.new': 'Nueva nota',
+ 'collab.notes.empty': 'Aún no hay notas',
+ 'collab.notes.emptyHint': 'Empieza a capturar ideas y planes',
+ 'collab.notes.all': 'Todas',
+ 'collab.notes.titlePlaceholder': 'Título de la nota',
+ 'collab.notes.contentPlaceholder': 'Escribe algo...',
+ 'collab.notes.categoryPlaceholder': 'Categoría',
+ 'collab.notes.newCategory': 'Nueva categoría...',
+ 'collab.notes.category': 'Categoría',
+ 'collab.notes.noCategory': 'Sin categoría',
+ 'collab.notes.color': 'Color',
+ 'collab.notes.save': 'Guardar',
+ 'collab.notes.cancel': 'Cancelar',
+ 'collab.notes.edit': 'Editar',
+ 'collab.notes.delete': 'Eliminar',
+ 'collab.notes.pin': 'Fijar',
+ 'collab.notes.unpin': 'Desfijar',
+ 'collab.notes.daysAgo': 'hace {n} d',
+ 'collab.notes.categorySettings': 'Gestionar categorías',
+ 'collab.notes.create': 'Crear',
+ 'collab.notes.website': 'Sitio web',
+ 'collab.notes.websitePlaceholder': 'https://...',
+ 'collab.notes.attachFiles': 'Adjuntar archivos',
+ 'collab.notes.noCategoriesYet': 'Aún no hay categorías',
+ 'collab.notes.emptyDesc': 'Crea una nota para empezar',
+ 'collab.polls.title': 'Encuestas',
+ 'collab.polls.new': 'Nueva encuesta',
+ 'collab.polls.empty': 'Aún no hay encuestas',
+ 'collab.polls.emptyHint': 'Pregunta al grupo y votad juntos',
+ 'collab.polls.question': 'Pregunta',
+ 'collab.polls.questionPlaceholder': '¿Qué deberíamos hacer?',
+ 'collab.polls.addOption': '+ Añadir opción',
+ 'collab.polls.optionPlaceholder': 'Opción {n}',
+ 'collab.polls.create': 'Crear encuesta',
+ 'collab.polls.close': 'Cerrar',
+ 'collab.polls.closed': 'Cerrada',
+ 'collab.polls.votes': '{n} votos',
+ 'collab.polls.vote': '{n} voto',
+ 'collab.polls.multipleChoice': 'Selección múltiple',
+ 'collab.polls.multiChoice': 'Selección múltiple',
+ 'collab.polls.deadline': 'Fecha límite',
+ 'collab.polls.option': 'Opción',
+ 'collab.polls.options': 'Opciones',
+ 'collab.polls.delete': 'Eliminar',
+ 'collab.polls.closedSection': 'Cerradas',
+
+ // Files management (2.6.2)
+ 'files.trash': 'Papelera',
+ 'files.trashEmpty': 'La papelera está vacía',
+ 'files.emptyTrash': 'Vaciar papelera',
+ 'files.restore': 'Restaurar',
+ 'files.star': 'Destacar',
+ 'files.unstar': 'Quitar destacado',
+ 'files.assign': 'Asignar',
+ 'files.assignTitle': 'Asignar archivo',
+ 'files.assignPlace': 'Lugar',
+ 'files.assignBooking': 'Reserva',
+ 'files.unassigned': 'Sin asignar',
+ 'files.unlink': 'Eliminar vínculo',
+ 'files.noteLabel': 'Nota',
+ 'files.notePlaceholder': 'Añadir una nota...',
+ 'files.toast.trashed': 'Movido a la papelera',
+ 'files.toast.restored': 'Archivo restaurado',
+ 'files.toast.trashEmptied': 'Papelera vaciada',
+ 'files.toast.assigned': 'Archivo asignado',
+ 'files.toast.assignError': 'Error al asignar',
+ 'files.toast.restoreError': 'Error al restaurar',
+ 'files.confirm.permanentDelete': 'Eliminar este archivo permanentemente? No se puede deshacer.',
+ 'files.confirm.emptyTrash': 'Eliminar todos los archivos de la papelera? No se puede deshacer.',
+
+ // Reservation metadata (2.6.2)
+ 'reservations.meta.airline': 'Aerolínea',
+ 'reservations.meta.flightNumber': 'N° de vuelo',
+ 'reservations.meta.from': 'Desde',
+ 'reservations.meta.to': 'Hasta',
+ 'reservations.meta.trainNumber': 'N° de tren',
+ 'reservations.meta.platform': 'Andén',
+ 'reservations.meta.seat': 'Asiento',
+ 'reservations.meta.checkIn': 'Check-in',
+ 'reservations.meta.checkOut': 'Check-out',
+ 'reservations.meta.linkAccommodation': 'Alojamiento',
+ 'reservations.meta.pickAccommodation': 'Vincular con alojamiento',
+ 'reservations.meta.noAccommodation': 'Ninguno',
+ 'reservations.meta.hotelPlace': 'Hotel',
+ 'reservations.meta.pickHotel': 'Seleccionar hotel',
+ 'reservations.meta.fromDay': 'Desde',
+ 'reservations.meta.toDay': 'Hasta',
+ 'reservations.meta.selectDay': 'Seleccionar día',
+
+ // OIDC-only mode (2.6.2)
+ 'admin.oidcOnlyMode': 'Desactivar autenticación por contraseña',
+ 'admin.oidcOnlyModeHint': 'Si está activado, solo se permite el inicio de sesión con SSO. El inicio de sesión y registro con contraseña se bloquean.',
+ 'login.oidcOnly': 'La autenticación por contraseña está desactivada. Por favor, inicia sesión con tu proveedor SSO.',
+
+ // Settings (2.6.2)
+ 'settings.currentPasswordRequired': 'La contraseña actual es obligatoria',
+ 'settings.passwordWeak': 'La contraseña debe contener mayúsculas, minúsculas y números',
+}
+
+export default es
diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts
new file mode 100644
index 0000000..12dcc79
--- /dev/null
+++ b/client/src/i18n/translations/fr.ts
@@ -0,0 +1,1082 @@
+const fr: Record = {
+ // Common
+ 'common.save': 'Enregistrer',
+ 'common.cancel': 'Annuler',
+ 'common.delete': 'Supprimer',
+ 'common.edit': 'Modifier',
+ 'common.add': 'Ajouter',
+ 'common.loading': 'Chargement...',
+ 'common.error': 'Erreur',
+ 'common.back': 'Retour',
+ 'common.all': 'Tout',
+ 'common.close': 'Fermer',
+ 'common.open': 'Ouvrir',
+ 'common.upload': 'Téléverser',
+ 'common.search': 'Rechercher',
+ 'common.confirm': 'Confirmer',
+ 'common.ok': 'OK',
+ 'common.yes': 'Oui',
+ 'common.no': 'Non',
+ 'common.or': 'ou',
+ 'common.none': 'Aucun',
+ 'common.date': 'Date',
+ 'common.rename': 'Renommer',
+ 'common.name': 'Nom',
+ 'common.email': 'E-mail',
+ 'common.password': 'Mot de passe',
+ 'common.saving': 'Enregistrement...',
+ 'common.update': 'Mettre à jour',
+ 'common.change': 'Modifier',
+ 'common.uploading': 'Téléversement…',
+ 'common.backToPlanning': 'Retour à la planification',
+ 'common.reset': 'Réinitialiser',
+
+ // Navbar
+ 'nav.trip': 'Voyage',
+ 'nav.share': 'Partager',
+ 'nav.settings': 'Paramètres',
+ 'nav.admin': 'Admin',
+ 'nav.logout': 'Déconnexion',
+ 'nav.lightMode': 'Mode clair',
+ 'nav.darkMode': 'Mode sombre',
+ 'nav.autoMode': 'Mode auto',
+ 'nav.administrator': 'Administrateur',
+
+ // Dashboard
+ 'dashboard.title': 'Mes 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',
+ 'dashboard.subtitle.activeMany': '{count} voyages actifs',
+ 'dashboard.subtitle.archivedSuffix': ' · {count} archivés',
+ 'dashboard.newTrip': 'Nouveau voyage',
+ 'dashboard.currency': 'Devise',
+ 'dashboard.timezone': 'Fuseaux horaires',
+ 'dashboard.localTime': 'Local',
+ 'dashboard.timezoneCustomTitle': 'Fuseau horaire personnalisé',
+ 'dashboard.timezoneCustomLabelPlaceholder': 'Libellé (facultatif)',
+ 'dashboard.timezoneCustomTzPlaceholder': 'ex. America/New_York',
+ 'dashboard.timezoneCustomAdd': 'Ajouter',
+ 'dashboard.timezoneCustomErrorEmpty': 'Saisissez un identifiant de fuseau horaire',
+ 'dashboard.timezoneCustomErrorInvalid': 'Fuseau horaire invalide. Utilisez un format comme Europe/Berlin',
+ 'dashboard.timezoneCustomErrorDuplicate': 'Déjà ajouté',
+ 'dashboard.emptyTitle': 'Aucun voyage',
+ 'dashboard.emptyText': 'Créez votre premier voyage et commencez à planifier !',
+ 'dashboard.emptyButton': 'Créer un premier voyage',
+ 'dashboard.nextTrip': 'Prochain voyage',
+ 'dashboard.shared': 'Partagé',
+ 'dashboard.sharedBy': 'Partagé par {name}',
+ 'dashboard.days': 'Jours',
+ 'dashboard.places': 'Lieux',
+ 'dashboard.archive': 'Archiver',
+ 'dashboard.restore': 'Restaurer',
+ 'dashboard.archived': 'Archivé',
+ 'dashboard.status.ongoing': 'En cours',
+ 'dashboard.status.today': "Aujourd'hui",
+ 'dashboard.status.tomorrow': 'Demain',
+ 'dashboard.status.past': 'Passé',
+ 'dashboard.status.daysLeft': '{count} jours restants',
+ 'dashboard.toast.loadError': 'Impossible de charger les voyages',
+ 'dashboard.toast.created': 'Voyage créé avec succès !',
+ 'dashboard.toast.createError': 'Impossible de créer le voyage',
+ 'dashboard.toast.updated': 'Voyage mis à jour !',
+ 'dashboard.toast.updateError': 'Impossible de mettre à jour le voyage',
+ 'dashboard.toast.deleted': 'Voyage supprimé',
+ 'dashboard.toast.deleteError': 'Impossible de supprimer le voyage',
+ 'dashboard.toast.archived': 'Voyage archivé',
+ 'dashboard.toast.archiveError': "Impossible d'archiver le voyage",
+ 'dashboard.toast.restored': 'Voyage restauré',
+ 'dashboard.toast.restoreError': 'Impossible de restaurer le voyage',
+ 'dashboard.confirm.delete': 'Supprimer le voyage « {title} » ? Tous les lieux et plans seront définitivement supprimés.',
+ 'dashboard.editTrip': 'Modifier le voyage',
+ 'dashboard.createTrip': 'Créer un nouveau voyage',
+ 'dashboard.tripTitle': 'Titre',
+ 'dashboard.tripTitlePlaceholder': 'ex. Été au Japon',
+ 'dashboard.tripDescription': 'Description',
+ 'dashboard.tripDescriptionPlaceholder': 'De quoi parle ce voyage ?',
+ 'dashboard.startDate': 'Date de début',
+ 'dashboard.endDate': 'Date de fin',
+ 'dashboard.noDateHint': 'Aucune date définie — 7 jours par défaut seront créés. Vous pouvez modifier cela à tout moment.',
+ 'dashboard.coverImage': 'Image de couverture',
+ 'dashboard.addCoverImage': 'Ajouter une image de couverture',
+ 'dashboard.coverSaved': 'Image de couverture enregistrée',
+ 'dashboard.coverUploadError': 'Échec du téléversement',
+ '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',
+
+ // Settings
+ 'settings.title': 'Paramètres',
+ '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.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',
+ 'settings.latitude': 'Latitude',
+ 'settings.longitude': 'Longitude',
+ 'settings.saveMap': 'Enregistrer la carte',
+ 'settings.apiKeys': 'Clés API',
+ 'settings.mapsKey': 'Clé API Google Maps',
+ '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.configured': 'Configuré',
+ 'settings.saveKeys': 'Enregistrer les clés',
+ 'settings.display': 'Affichage',
+ 'settings.colorMode': 'Mode de couleur',
+ 'settings.light': 'Clair',
+ 'settings.dark': 'Sombre',
+ 'settings.auto': 'Auto',
+ 'settings.language': 'Langue',
+ 'settings.temperature': 'Unité de température',
+ 'settings.timeFormat': 'Format de l\'heure',
+ 'settings.routeCalculation': 'Calcul d\'itinéraire',
+ 'settings.on': 'Activé',
+ 'settings.off': 'Désactivé',
+ 'settings.account': 'Compte',
+ 'settings.username': 'Nom d\'utilisateur',
+ 'settings.email': 'E-mail',
+ 'settings.role': 'Rôle',
+ 'settings.roleAdmin': 'Administrateur',
+ 'settings.oidcLinked': 'Lié avec',
+ 'settings.changePassword': 'Changer le mot de passe',
+ 'settings.currentPassword': 'Mot de passe actuel',
+ 'settings.currentPasswordRequired': 'Le mot de passe actuel est requis',
+ 'settings.newPassword': 'Nouveau mot de passe',
+ 'settings.confirmPassword': 'Confirmer le nouveau mot de passe',
+ 'settings.updatePassword': 'Mettre à jour le mot de passe',
+ 'settings.passwordRequired': 'Veuillez saisir le mot de passe actuel et le nouveau',
+ 'settings.passwordTooShort': 'Le mot de passe doit comporter au moins 8 caractères',
+ 'settings.passwordMismatch': 'Les mots de passe ne correspondent pas',
+ 'settings.passwordWeak': 'Le mot de passe doit contenir des majuscules, des minuscules et un chiffre',
+ 'settings.passwordChanged': 'Mot de passe modifié avec succès',
+ 'settings.deleteAccount': 'Supprimer le compte',
+ 'settings.deleteAccountTitle': 'Supprimer votre compte ?',
+ 'settings.deleteAccountWarning': 'Votre compte ainsi que tous vos voyages, lieux et fichiers seront définitivement supprimés. Cette action est irréversible.',
+ 'settings.deleteAccountConfirm': 'Supprimer définitivement',
+ 'settings.deleteBlockedTitle': 'Suppression impossible',
+ 'settings.deleteBlockedMessage': 'Vous êtes le seul administrateur. Promouvez un autre utilisateur en tant qu\'administrateur avant de supprimer votre compte.',
+ 'settings.roleUser': 'Utilisateur',
+ 'settings.saveProfile': 'Enregistrer le profil',
+ 'settings.toast.mapSaved': 'Paramètres de carte enregistrés',
+ '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.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',
+
+ // Login
+ 'login.error': 'Échec de la connexion. Veuillez vérifier vos identifiants.',
+ 'login.tagline': 'Vos voyages.\nVotre plan.',
+ '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',
+ 'login.features.realtime': 'Synchronisation en temps réel',
+ 'login.features.realtimeDesc': 'Planifiez ensemble via WebSocket',
+ 'login.features.budget': 'Suivi du budget',
+ 'login.features.budgetDesc': 'Catégories, graphiques et coûts par personne',
+ 'login.features.collab': 'Collaboration',
+ 'login.features.collabDesc': 'Multi-utilisateurs avec voyages partagés',
+ 'login.features.packing': 'Listes de bagages',
+ 'login.features.packingDesc': 'Catégories, progression et suggestions',
+ '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.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',
+ 'login.title': 'Connexion',
+ 'login.subtitle': 'Bon retour',
+ 'login.signingIn': 'Connexion en cours…',
+ 'login.signIn': 'Se connecter',
+ 'login.createAdmin': 'Créer un compte administrateur',
+ 'login.createAdminHint': 'Configurez le premier compte administrateur pour TREK.',
+ 'login.createAccount': 'Créer un compte',
+ 'login.createAccountHint': 'Créez un nouveau compte.',
+ 'login.creating': 'Création…',
+ 'login.noAccount': 'Pas encore de compte ?',
+ 'login.hasAccount': 'Vous avez déjà un compte ?',
+ 'login.register': 'S\'inscrire',
+ 'login.emailPlaceholder': 'votre@email.com',
+ 'login.username': 'Nom d\'utilisateur',
+ 'login.oidc.registrationDisabled': 'Les inscriptions sont désactivées. Contactez votre administrateur.',
+ 'login.oidc.noEmail': 'Aucun e-mail reçu du fournisseur.',
+ 'login.oidc.tokenFailed': 'L\'authentification a échoué.',
+ 'login.oidc.invalidState': 'Session invalide. Veuillez réessayer.',
+ 'login.demoFailed': 'Échec de la connexion démo',
+ 'login.oidcSignIn': 'Se connecter avec {name}',
+ 'login.oidcOnly': 'L\'authentification par mot de passe est désactivée. Veuillez vous connecter via votre fournisseur SSO.',
+ 'login.demoHint': 'Essayez la démo — aucune inscription nécessaire',
+
+ // Register
+ 'register.passwordMismatch': 'Les mots de passe ne correspondent pas',
+ 'register.passwordTooShort': 'Le mot de passe doit comporter au moins 6 caractères',
+ 'register.failed': 'Échec de l\'inscription',
+ 'register.getStarted': 'Commencer',
+ 'register.subtitle': 'Créez un compte et commencez à planifier vos voyages de rêve.',
+ 'register.feature1': 'Plans de voyage illimités',
+ 'register.feature2': 'Vue carte interactive',
+ 'register.feature3': 'Gérez les lieux et catégories',
+ 'register.feature4': 'Suivez les réservations',
+ 'register.feature5': 'Créez des listes de bagages',
+ 'register.feature6': 'Stockez photos et fichiers',
+ 'register.createAccount': 'Créer un compte',
+ 'register.startPlanning': 'Commencez à planifier vos voyages',
+ '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.register': 'S\'inscrire',
+ 'register.hasAccount': 'Vous avez déjà un compte ?',
+ 'register.signIn': 'Se connecter',
+
+ // Admin
+ 'admin.title': 'Administration',
+ 'admin.subtitle': 'Gestion des utilisateurs et paramètres système',
+ 'admin.tabs.users': 'Utilisateurs',
+ 'admin.tabs.categories': 'Catégories',
+ 'admin.tabs.backup': 'Sauvegarde',
+ 'admin.stats.users': 'Utilisateurs',
+ 'admin.stats.trips': 'Voyages',
+ 'admin.stats.places': 'Lieux',
+ 'admin.stats.photos': 'Photos',
+ 'admin.stats.files': 'Fichiers',
+ 'admin.table.user': 'Utilisateur',
+ 'admin.table.email': 'E-mail',
+ 'admin.table.role': 'Rôle',
+ 'admin.table.created': 'Créé le',
+ 'admin.table.lastLogin': 'Dernière connexion',
+ 'admin.table.actions': 'Actions',
+ 'admin.you': '(Vous)',
+ 'admin.editUser': 'Modifier l\'utilisateur',
+ 'admin.newPassword': 'Nouveau mot de passe',
+ 'admin.newPasswordHint': 'Laissez vide pour conserver le mot de passe actuel',
+ 'admin.deleteUser': 'Supprimer l\'utilisateur « {name} » ? Tous les voyages seront définitivement supprimés.',
+ 'admin.deleteUserTitle': 'Supprimer l\'utilisateur',
+ 'admin.newPasswordPlaceholder': 'Saisir le nouveau mot de passe…',
+ 'admin.toast.loadError': 'Impossible de charger les données d\'administration',
+ 'admin.toast.userUpdated': 'Utilisateur mis à jour',
+ 'admin.toast.updateError': 'Échec de la mise à jour',
+ 'admin.toast.userDeleted': 'Utilisateur supprimé',
+ 'admin.toast.deleteError': 'Échec de la suppression',
+ 'admin.toast.cannotDeleteSelf': 'Impossible de supprimer votre propre compte',
+ 'admin.toast.userCreated': 'Utilisateur créé',
+ 'admin.toast.createError': 'Échec de la création de l\'utilisateur',
+ 'admin.toast.fieldsRequired': 'Le nom d\'utilisateur, l\'e-mail et le mot de passe sont requis',
+ 'admin.createUser': 'Créer un utilisateur',
+ 'admin.tabs.settings': 'Paramètres',
+ 'admin.allowRegistration': 'Autoriser les inscriptions',
+ 'admin.allowRegistrationHint': 'Les nouveaux utilisateurs peuvent s\'inscrire eux-mêmes',
+ 'admin.apiKeys': 'Clés API',
+ 'admin.apiKeysHint': 'Facultatif. Active les données de lieu étendues comme les photos et la météo.',
+ 'admin.mapsKey': 'Clé API Google Maps',
+ 'admin.mapsKeyHint': 'Requise pour la recherche de lieux. Obtenez-la sur console.cloud.google.com',
+ 'admin.mapsKeyHintLong': 'Sans clé API, OpenStreetMap est utilisé pour la recherche de lieux. Avec une clé Google API, les photos, notes et horaires d\'ouverture peuvent également être chargés. Obtenez-en une sur console.cloud.google.com.',
+ 'admin.recommended': 'Recommandé',
+ 'admin.weatherKey': 'Clé API OpenWeatherMap',
+ 'admin.weatherKeyHint': 'Pour les données météo. Gratuit sur openweathermap.org',
+ 'admin.validateKey': 'Tester',
+ 'admin.keyValid': 'Connecté',
+ 'admin.keyInvalid': 'Invalide',
+ 'admin.keySaved': 'Clés API enregistrées',
+ 'admin.oidcTitle': 'Authentification unique (OIDC)',
+ 'admin.oidcSubtitle': 'Autorisez la connexion via des fournisseurs externes comme Google, Apple, Authentik ou Keycloak.',
+ 'admin.oidcDisplayName': 'Nom d\'affichage',
+ 'admin.oidcIssuer': 'URL de l\'émetteur',
+ 'admin.oidcIssuerHint': 'L\'URL de l\'émetteur OpenID Connect du fournisseur. ex. https://accounts.google.com',
+ 'admin.oidcSaved': 'Configuration OIDC enregistrée',
+ 'admin.oidcOnlyMode': 'Désactiver l\'authentification par mot de passe',
+ 'admin.oidcOnlyModeHint': 'Lorsqu\'activé, seule la connexion SSO est autorisée. La connexion et l\'inscription par mot de passe sont bloquées.',
+
+ // File Types
+ 'admin.fileTypes': 'Types de fichiers autorisés',
+ 'admin.fileTypesHint': 'Configurez les types de fichiers que les utilisateurs peuvent téléverser.',
+ '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',
+
+ // Addons
+ 'admin.tabs.addons': 'Extensions',
+ 'admin.addons.title': 'Extensions',
+ 'admin.addons.subtitle': 'Activez ou désactivez des fonctionnalités pour personnaliser votre expérience TREK.',
+ 'admin.addons.catalog.memories.name': 'Souvenirs',
+ 'admin.addons.catalog.memories.description': 'Albums photo partagés pour chaque voyage',
+ 'admin.addons.catalog.packing.name': 'Bagages',
+ 'admin.addons.catalog.packing.description': 'Listes de contrôle pour préparer vos bagages pour chaque voyage',
+ 'admin.addons.catalog.budget.name': 'Budget',
+ '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.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.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': '.',
+ 'admin.addons.enabled': 'Activé',
+ 'admin.addons.disabled': 'Désactivé',
+ 'admin.addons.type.trip': 'Voyage',
+ 'admin.addons.type.global': 'Global',
+ 'admin.addons.tripHint': 'Disponible comme onglet dans chaque voyage',
+ 'admin.addons.globalHint': 'Disponible comme section autonome dans la navigation principale',
+ 'admin.addons.toast.updated': 'Extension mise à jour',
+ 'admin.addons.toast.error': 'Échec de la mise à jour de l\'extension',
+ 'admin.addons.noAddons': 'Aucune extension disponible',
+ // Weather info
+ 'admin.weather.title': 'Données météo',
+ 'admin.weather.badge': 'Depuis le 24 mars 2026',
+ 'admin.weather.description': 'TREK utilise Open-Meteo comme source de données météo. Open-Meteo est un service météo gratuit et open source — aucune clé API requise.',
+ 'admin.weather.forecast': 'Prévisions sur 16 jours',
+ 'admin.weather.forecastDesc': 'Auparavant 5 jours (OpenWeatherMap)',
+ 'admin.weather.climate': 'Données climatiques historiques',
+ '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.',
+
+ // GitHub
+ 'admin.tabs.github': 'GitHub',
+ 'admin.github.title': 'Historique des versions',
+ 'admin.github.subtitle': 'Dernières mises à jour de {repo}',
+ 'admin.github.latest': 'Dernière',
+ 'admin.github.prerelease': 'Pré-version',
+ '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.error': 'Impossible de charger les versions',
+ 'admin.github.by': 'par',
+
+ 'admin.update.available': 'Mise à jour disponible',
+ 'admin.update.text': 'TREK {version} est disponible. Vous utilisez {current}.',
+ 'admin.update.button': 'Voir sur GitHub',
+ '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.warning': 'L\'application sera brièvement indisponible pendant le redémarrage.',
+ 'admin.update.confirm': 'Mettre à jour maintenant',
+ 'admin.update.installing': 'Mise à jour…',
+ 'admin.update.success': 'Mise à jour installée ! Le serveur redémarre…',
+ 'admin.update.failed': 'Échec de la mise à jour',
+ 'admin.update.backupHint': 'Nous recommandons de créer une sauvegarde avant la mise à jour.',
+ 'admin.update.backupLink': 'Aller aux sauvegardes',
+ 'admin.update.howTo': 'Comment mettre à jour',
+ 'admin.update.dockerText': 'Votre instance TREK fonctionne dans Docker. Pour mettre à jour vers {version}, exécutez les commandes suivantes sur votre serveur :',
+ 'admin.update.reloadHint': 'Veuillez recharger la page dans quelques secondes.',
+
+ // Vacay addon
+ 'vacay.subtitle': 'Planifiez et gérez vos jours de congé',
+ 'vacay.settings': 'Paramètres',
+ 'vacay.year': 'Année',
+ 'vacay.addYear': 'Ajouter une année',
+ 'vacay.removeYear': 'Supprimer l\'année',
+ 'vacay.removeYearConfirm': 'Supprimer {year} ?',
+ 'vacay.removeYearHint': 'Toutes les entrées de vacances et jours fériés d\'entreprise de cette année seront définitivement supprimés.',
+ 'vacay.remove': 'Supprimer',
+ 'vacay.persons': 'Personnes',
+ 'vacay.noPersons': 'Aucune personne ajoutée',
+ 'vacay.addPerson': 'Ajouter une personne',
+ 'vacay.editPerson': 'Modifier la personne',
+ 'vacay.removePerson': 'Supprimer la personne',
+ 'vacay.removePersonConfirm': 'Supprimer {name} ?',
+ 'vacay.removePersonHint': 'Toutes les entrées de vacances de cette personne seront définitivement supprimées.',
+ 'vacay.personName': 'Nom',
+ 'vacay.personNamePlaceholder': 'Saisir le nom',
+ 'vacay.color': 'Couleur',
+ 'vacay.add': 'Ajouter',
+ 'vacay.legend': 'Légende',
+ 'vacay.publicHoliday': 'Jour férié',
+ 'vacay.companyHoliday': 'Jour férié d\'entreprise',
+ 'vacay.weekend': 'Week-end',
+ 'vacay.modeVacation': 'Vacances',
+ 'vacay.modeCompany': 'Jour férié d\'entreprise',
+ 'vacay.entitlement': 'Droits',
+ 'vacay.entitlementDays': 'Jours',
+ 'vacay.used': 'Utilisés',
+ 'vacay.remaining': 'Restants',
+ 'vacay.carriedOver': 'de {year}',
+ 'vacay.blockWeekends': 'Bloquer les week-ends',
+ 'vacay.blockWeekendsHint': 'Empêcher les entrées de vacances les samedis et dimanches',
+ 'vacay.publicHolidays': 'Jours fériés',
+ 'vacay.publicHolidaysHint': 'Marquer les jours fériés dans le calendrier',
+ 'vacay.selectCountry': 'Sélectionner un pays',
+ 'vacay.selectRegion': 'Sélectionner une région (facultatif)',
+ 'vacay.companyHolidays': 'Jours fériés d\'entreprise',
+ 'vacay.companyHolidaysHint': 'Autoriser le marquage des jours fériés d\'entreprise',
+ 'vacay.companyHolidaysNoDeduct': 'Les jours fériés d\'entreprise ne sont pas déduits des jours de vacances.',
+ 'vacay.carryOver': 'Report',
+ 'vacay.carryOverHint': 'Reporter automatiquement les jours de vacances restants à l\'année suivante',
+ 'vacay.sharing': 'Partage',
+ 'vacay.sharingHint': 'Partagez votre plan de vacances avec d\'autres utilisateurs TREK',
+ 'vacay.owner': 'Propriétaire',
+ '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.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.you': 'vous',
+ 'vacay.noData': 'Aucune donnée',
+ 'vacay.changeColor': 'Changer la couleur',
+ 'vacay.inviteUser': 'Inviter un utilisateur',
+ 'vacay.inviteHint': 'Invitez un autre utilisateur TREK à partager un calendrier de vacances combiné.',
+ 'vacay.selectUser': 'Sélectionner un utilisateur',
+ 'vacay.sendInvite': 'Envoyer l\'invitation',
+ 'vacay.inviteSent': 'Invitation envoyée',
+ 'vacay.inviteError': 'Impossible d\'envoyer l\'invitation',
+ 'vacay.pending': 'en attente',
+ 'vacay.noUsersAvailable': 'Aucun utilisateur disponible',
+ 'vacay.accept': 'Accepter',
+ 'vacay.decline': 'Refuser',
+ 'vacay.acceptFusion': 'Accepter et fusionner',
+ 'vacay.inviteTitle': 'Demande de fusion',
+ 'vacay.inviteWantsToFuse': 'souhaite partager un calendrier de vacances avec vous.',
+ 'vacay.fuseInfo1': 'Vous verrez tous les deux toutes les entrées de vacances dans un calendrier partagé.',
+ 'vacay.fuseInfo2': 'Les deux parties peuvent créer et modifier des entrées pour l\'autre.',
+ 'vacay.fuseInfo3': 'Les deux parties peuvent supprimer des entrées et modifier les droits aux vacances.',
+ 'vacay.fuseInfo4': 'Les paramètres comme les jours fériés et les jours d\'entreprise sont partagés.',
+ 'vacay.fuseInfo5': 'La fusion peut être dissoute à tout moment par l\'une ou l\'autre partie. Vos entrées seront préservées.',
+ 'nav.myTrips': 'Mes voyages',
+
+ // Atlas addon
+ 'atlas.subtitle': 'Votre empreinte de voyage à travers le monde',
+ 'atlas.countries': 'Pays',
+ 'atlas.trips': 'Voyages',
+ 'atlas.places': 'Lieux',
+ 'atlas.days': 'Jours',
+ 'atlas.visitedCountries': 'Pays visités',
+ 'atlas.cities': 'Villes',
+ 'atlas.noData': 'Aucune donnée de voyage',
+ 'atlas.noDataHint': 'Créez un voyage et ajoutez des lieux pour voir votre carte du monde',
+ 'atlas.lastTrip': 'Dernier voyage',
+ 'atlas.nextTrip': 'Prochain voyage',
+ 'atlas.daysLeft': 'jours restants',
+ 'atlas.streak': 'Série',
+ 'atlas.year': 'an',
+ 'atlas.years': 'ans',
+ 'atlas.yearInRow': 'année consécutive',
+ 'atlas.yearsInRow': 'années consécutives',
+ 'atlas.tripIn': 'voyage en',
+ 'atlas.tripsIn': 'voyages en',
+ 'atlas.since': 'depuis',
+ 'atlas.europe': 'Europe',
+ 'atlas.asia': 'Asie',
+ 'atlas.northAmerica': 'Amérique du N.',
+ 'atlas.southAmerica': 'Amérique du S.',
+ 'atlas.africa': 'Afrique',
+ 'atlas.oceania': 'Océanie',
+ 'atlas.other': 'Autre',
+ 'atlas.firstVisit': 'Premier voyage',
+ 'atlas.lastVisitLabel': 'Dernier voyage',
+ 'atlas.tripSingular': 'Voyage',
+ 'atlas.tripPlural': 'Voyages',
+ 'atlas.placeVisited': 'Lieu visité',
+ 'atlas.placesVisited': 'Lieux visités',
+
+ // Trip Planner
+ 'trip.tabs.plan': 'Plan',
+ 'trip.tabs.reservations': 'Réservations',
+ 'trip.tabs.reservationsShort': 'Résa',
+ 'trip.tabs.packing': 'Liste de bagages',
+ 'trip.tabs.packingShort': 'Bagages',
+ 'trip.tabs.budget': 'Budget',
+ 'trip.tabs.files': 'Fichiers',
+ '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.reorderError': 'Échec de la réorganisation',
+ 'trip.toast.reservationUpdated': 'Réservation mise à jour',
+ 'trip.toast.reservationAdded': 'Réservation ajoutée',
+ 'trip.toast.deleted': 'Supprimé',
+ 'trip.confirm.deletePlace': 'Voulez-vous vraiment supprimer ce lieu ?',
+
+ // Day Plan Sidebar
+ 'dayplan.emptyDay': 'Aucun lieu prévu pour ce jour',
+ 'dayplan.addNote': 'Ajouter une note',
+ 'dayplan.editNote': 'Modifier la note',
+ 'dayplan.noteAdd': 'Ajouter une note',
+ 'dayplan.noteEdit': 'Modifier la note',
+ 'dayplan.noteTitle': 'Note',
+ 'dayplan.noteSubtitle': 'Note du jour',
+ 'dayplan.totalCost': 'Coût total',
+ 'dayplan.days': 'Jours',
+ 'dayplan.dayN': 'Jour {n}',
+ 'dayplan.calculating': 'Calcul en cours...',
+ 'dayplan.route': 'Itinéraire',
+ 'dayplan.optimize': 'Optimiser',
+ 'dayplan.optimized': 'Itinéraire optimisé',
+ 'dayplan.routeError': 'Impossible de calculer l\'itinéraire',
+ 'dayplan.toast.needTwoPlaces': 'Au moins deux lieux nécessaires pour optimiser l\'itinéraire',
+ 'dayplan.toast.routeOptimized': 'Itinéraire optimisé',
+ 'dayplan.toast.noGeoPlaces': 'Aucun lieu avec des coordonnées trouvé pour le calcul d\'itinéraire',
+ 'dayplan.confirmed': 'Confirmé',
+ 'dayplan.pendingRes': 'En attente',
+ 'dayplan.pdf': 'PDF',
+ 'dayplan.pdfTooltip': 'Exporter le plan du jour en PDF',
+ 'dayplan.pdfError': 'Échec de l\'export PDF',
+
+ // Places Sidebar
+ 'places.addPlace': 'Ajouter un lieu/activité',
+ 'places.assignToDay': 'Ajouter à quel jour ?',
+ 'places.all': 'Tous',
+ 'places.unplanned': 'Non planifiés',
+ 'places.search': 'Rechercher des lieux...',
+ 'places.allCategories': 'Toutes les catégories',
+ 'places.count': '{count} lieux',
+ 'places.countSingular': '1 lieu',
+ 'places.allPlanned': 'Tous les lieux sont planifiés',
+ 'places.noneFound': 'Aucun lieu trouvé',
+ 'places.editPlace': 'Modifier le lieu',
+ 'places.formName': 'Nom',
+ 'places.formNamePlaceholder': 'ex. Tour Eiffel',
+ 'places.formDescription': 'Description',
+ 'places.formDescriptionPlaceholder': 'Brève description...',
+ 'places.formAddress': 'Adresse',
+ 'places.formAddressPlaceholder': 'Rue, ville, pays',
+ 'places.formLat': 'Latitude (ex. 48.8566)',
+ 'places.formLng': 'Longitude (ex. 2.3522)',
+ 'places.formCategory': 'Catégorie',
+ 'places.noCategory': 'Sans catégorie',
+ 'places.categoryNamePlaceholder': 'Nom de la catégorie',
+ 'places.formTime': 'Heure',
+ 'places.startTime': 'Début',
+ 'places.endTime': 'Fin',
+ '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.formReservation': 'Réservation',
+ '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.',
+ 'places.categoryCreateError': 'Impossible de créer la catégorie',
+ 'places.nameRequired': 'Veuillez saisir un nom',
+ 'places.saveError': 'Échec de l\'enregistrement',
+ // Place Inspector
+ 'inspector.opened': 'Ouvert',
+ 'inspector.closed': 'Fermé',
+ 'inspector.openingHours': 'Horaires d\'ouverture',
+ 'inspector.showHours': 'Afficher les horaires',
+ 'inspector.files': 'Fichiers',
+ 'inspector.filesCount': '{count} fichiers',
+ 'inspector.removeFromDay': 'Retirer du jour',
+ 'inspector.addToDay': 'Ajouter au jour',
+ 'inspector.confirmedRes': 'Réservation confirmée',
+ 'inspector.pendingRes': 'Réservation en attente',
+ 'inspector.google': 'Ouvrir dans Google Maps',
+ 'inspector.website': 'Ouvrir le site web',
+ 'inspector.addRes': 'Réservation',
+ 'inspector.editRes': 'Modifier la réservation',
+ 'inspector.participants': 'Participants',
+
+ // Reservations
+ 'reservations.title': 'Réservations',
+ 'reservations.empty': 'Aucune réservation',
+ 'reservations.emptyHint': 'Ajoutez des réservations pour les vols, hôtels et plus',
+ 'reservations.add': 'Ajouter une réservation',
+ 'reservations.addManual': 'Réservation manuelle',
+ 'reservations.placeHint': 'Conseil : les réservations sont mieux créées directement depuis un lieu pour les lier à votre plan du jour.',
+ 'reservations.confirmed': 'Confirmée',
+ 'reservations.pending': 'En attente',
+ 'reservations.summary': '{confirmed} confirmées, {pending} en attente',
+ 'reservations.fromPlan': 'Du plan',
+ 'reservations.showFiles': 'Afficher les fichiers',
+ 'reservations.editTitle': 'Modifier la réservation',
+ 'reservations.status': 'Statut',
+ 'reservations.datetime': 'Date et heure',
+ 'reservations.startTime': 'Heure de début',
+ 'reservations.endTime': 'Heure de fin',
+ 'reservations.date': 'Date',
+ 'reservations.time': 'Heure',
+ 'reservations.timeAlt': 'Heure (alternative, ex. 19h30)',
+ 'reservations.notes': 'Notes',
+ 'reservations.notesPlaceholder': 'Notes supplémentaires...',
+ 'reservations.meta.airline': 'Compagnie aérienne',
+ 'reservations.meta.flightNumber': 'N° de vol',
+ 'reservations.meta.from': 'De',
+ 'reservations.meta.to': 'À',
+ 'reservations.meta.trainNumber': 'N° de train',
+ 'reservations.meta.platform': 'Quai',
+ 'reservations.meta.seat': 'Place',
+ 'reservations.meta.checkIn': 'Arrivée',
+ 'reservations.meta.checkOut': 'Départ',
+ 'reservations.meta.linkAccommodation': 'Hébergement',
+ 'reservations.meta.pickAccommodation': 'Lier à un hébergement',
+ 'reservations.meta.noAccommodation': 'Aucun',
+ 'reservations.meta.hotelPlace': 'Hôtel',
+ 'reservations.meta.pickHotel': 'Sélectionner un hôtel',
+ 'reservations.meta.fromDay': 'Du',
+ 'reservations.meta.toDay': 'Au',
+ 'reservations.meta.selectDay': 'Sélectionner un jour',
+ 'reservations.type.flight': 'Vol',
+ 'reservations.type.hotel': 'Hôtel',
+ 'reservations.type.restaurant': 'Restaurant',
+ 'reservations.type.train': 'Train',
+ 'reservations.type.car': 'Voiture de location',
+ 'reservations.type.cruise': 'Croisière',
+ 'reservations.type.event': 'Événement',
+ 'reservations.type.tour': 'Visite',
+ 'reservations.type.other': 'Autre',
+ 'reservations.confirm.delete': 'Voulez-vous vraiment supprimer la réservation « {name} » ?',
+ 'reservations.toast.updated': 'Réservation mise à jour',
+ 'reservations.toast.removed': 'Réservation supprimée',
+ 'reservations.toast.fileUploaded': 'Fichier téléversé',
+ 'reservations.toast.uploadError': 'Échec du téléversement',
+ 'reservations.newTitle': 'Nouvelle réservation',
+ 'reservations.bookingType': 'Type de réservation',
+ 'reservations.titleLabel': 'Titre',
+ 'reservations.titlePlaceholder': 'ex. Lufthansa LH123, Hôtel Adlon, ...',
+ 'reservations.locationAddress': 'Lieu / Adresse',
+ 'reservations.locationPlaceholder': 'Adresse, aéroport, hôtel...',
+ 'reservations.confirmationCode': 'Code de réservation',
+ 'reservations.confirmationPlaceholder': 'ex. ABC12345',
+ 'reservations.day': 'Jour',
+ 'reservations.noDay': 'Aucun jour',
+ 'reservations.place': 'Lieu',
+ 'reservations.noPlace': 'Aucun lieu',
+ 'reservations.pendingSave': 'sera enregistré…',
+ 'reservations.uploading': 'Téléversement...',
+ 'reservations.attachFile': 'Joindre un fichier',
+ '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.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.createCategory': 'Créer une catégorie',
+ 'budget.category': 'Catégorie',
+ 'budget.categoryName': 'Nom de la catégorie',
+ 'budget.table.name': 'Nom',
+ 'budget.table.total': 'Total',
+ 'budget.table.persons': 'Personnes',
+ 'budget.table.days': 'Jours',
+ 'budget.table.perPerson': 'Par personne',
+ 'budget.table.perDay': 'Par jour',
+ 'budget.table.perPersonDay': 'P. p / Jour',
+ 'budget.table.note': 'Note',
+ 'budget.newEntry': 'Nouvelle entrée',
+ 'budget.defaultEntry': 'Nouvelle entrée',
+ 'budget.defaultCategory': 'Nouvelle catégorie',
+ 'budget.total': 'Total',
+ 'budget.totalBudget': 'Budget total',
+ 'budget.byCategory': 'Par catégorie',
+ 'budget.editTooltip': 'Cliquer pour modifier',
+ 'budget.confirm.deleteCategory': 'Voulez-vous vraiment supprimer la catégorie « {name} » avec {count} entrées ?',
+ 'budget.deleteCategory': 'Supprimer la catégorie',
+ 'budget.perPerson': 'Par personne',
+ 'budget.paid': 'Payé',
+ 'budget.open': 'Ouvert',
+ 'budget.noMembers': 'Aucun membre assigné',
+
+ // Files
+ 'files.title': 'Fichiers',
+ 'files.count': '{count} fichiers',
+ 'files.countSingular': '1 fichier',
+ 'files.uploaded': '{count} téléversés',
+ 'files.uploadError': 'Échec du téléversement',
+ 'files.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.filterAll': 'Tous',
+ 'files.filterPdf': 'PDF',
+ 'files.filterImages': 'Images',
+ 'files.filterDocs': 'Documents',
+ '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.openTab': 'Ouvrir dans un nouvel onglet',
+ 'files.confirm.delete': 'Voulez-vous vraiment supprimer ce fichier ?',
+ 'files.toast.deleted': 'Fichier supprimé',
+ 'files.toast.deleteError': 'Impossible de supprimer le fichier',
+ 'files.sourcePlan': 'Plan du jour',
+ 'files.sourceBooking': 'Réservation',
+ 'files.attach': 'Joindre',
+ 'files.pasteHint': 'Vous pouvez aussi coller des images depuis le presse-papiers (Ctrl+V)',
+ 'files.trash': 'Corbeille',
+ 'files.trashEmpty': 'La corbeille est vide',
+ 'files.emptyTrash': 'Vider la corbeille',
+ 'files.restore': 'Restaurer',
+ 'files.star': 'Favori',
+ 'files.unstar': 'Retirer des favoris',
+ 'files.assign': 'Assigner',
+ 'files.assignTitle': 'Assigner le fichier',
+ 'files.assignPlace': 'Lieu',
+ 'files.assignBooking': 'Réservation',
+ 'files.unassigned': 'Non assigné',
+ '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.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...',
+
+ // Packing
+ 'packing.title': 'Liste de bagages',
+ 'packing.empty': 'La liste de bagages est vide',
+ 'packing.progress': '{packed} sur {total} emballés ({percent} %)',
+ 'packing.clearChecked': 'Supprimer {count} cochés',
+ 'packing.clearCheckedShort': 'Supprimer {count}',
+ 'packing.suggestions': 'Suggestions',
+ '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.filterAll': 'Tous',
+ 'packing.filterOpen': 'À faire',
+ 'packing.filterDone': 'Fait',
+ 'packing.emptyTitle': 'La liste de bagages est vide',
+ 'packing.emptyHint': 'Ajoutez des articles ou utilisez les suggestions',
+ 'packing.emptyFiltered': 'Aucun article ne correspond à ce filtre',
+ 'packing.menuRename': 'Renommer',
+ 'packing.menuCheckAll': 'Tout cocher',
+ 'packing.menuUncheckAll': 'Tout décocher',
+ 'packing.menuDeleteCat': 'Supprimer la catégorie',
+ 'packing.changeCategory': 'Changer de catégorie',
+ 'packing.confirm.clearChecked': 'Voulez-vous vraiment supprimer {count} articles cochés ?',
+ 'packing.confirm.deleteCat': 'Voulez-vous vraiment supprimer la catégorie « {name} » avec {count} articles ?',
+ 'packing.defaultCategory': 'Autre',
+ 'packing.toast.saveError': 'Échec de l\'enregistrement',
+ 'packing.toast.deleteError': 'Échec de la suppression',
+ 'packing.toast.renameError': 'Échec du renommage',
+ 'packing.toast.addError': 'Échec de l\'ajout',
+
+ // Packing suggestions
+ 'packing.suggestions.items': [
+ { name: 'Passeport', category: 'Documents' },
+ { name: 'Carte d\'identité', category: 'Documents' },
+ { name: 'Assurance voyage', category: 'Documents' },
+ { name: 'Billets d\'avion', category: 'Documents' },
+ { name: 'Carte bancaire', category: 'Finances' },
+ { name: 'Espèces', category: 'Finances' },
+ { name: 'Visa', category: 'Documents' },
+ { name: 'T-shirts', category: 'Vêtements' },
+ { name: 'Pantalons', category: 'Vêtements' },
+ { name: 'Sous-vêtements', category: 'Vêtements' },
+ { name: 'Chaussettes', category: 'Vêtements' },
+ { name: 'Veste', category: 'Vêtements' },
+ { name: 'Pyjama', category: 'Vêtements' },
+ { name: 'Maillot de bain', category: 'Vêtements' },
+ { name: 'Imperméable', category: 'Vêtements' },
+ { name: 'Chaussures confortables', category: 'Vêtements' },
+ { name: 'Brosse à dents', category: 'Hygiène' },
+ { name: 'Dentifrice', category: 'Hygiène' },
+ { name: 'Shampooing', category: 'Hygiène' },
+ { name: 'Déodorant', category: 'Hygiène' },
+ { name: 'Crème solaire', category: 'Hygiène' },
+ { name: 'Rasoir', category: 'Hygiène' },
+ { name: 'Chargeur', category: 'Électronique' },
+ { name: 'Batterie externe', category: 'Électronique' },
+ { name: 'Écouteurs', category: 'Électronique' },
+ { name: 'Adaptateur de voyage', category: 'Électronique' },
+ { name: 'Appareil photo', category: 'Électronique' },
+ { name: 'Antidouleurs', category: 'Santé' },
+ { name: 'Pansements', category: 'Santé' },
+ { name: 'Désinfectant', category: 'Santé' },
+ ],
+
+ // Members / Sharing
+ 'members.shareTrip': 'Partager le voyage',
+ 'members.inviteUser': 'Inviter un utilisateur',
+ 'members.selectUser': 'Sélectionner un utilisateur…',
+ 'members.invite': 'Inviter',
+ 'members.allHaveAccess': 'Tous les utilisateurs ont déjà accès.',
+ 'members.access': 'Accès',
+ 'members.person': 'personne',
+ 'members.persons': 'personnes',
+ 'members.you': 'vous',
+ 'members.owner': 'Propriétaire',
+ 'members.leaveTrip': 'Quitter le voyage',
+ 'members.removeAccess': 'Retirer l\'accès',
+ 'members.confirmLeave': 'Quitter le voyage ? Vous perdrez l\'accès.',
+ 'members.confirmRemove': 'Retirer l\'accès à cet utilisateur ?',
+ 'members.loadError': 'Impossible de charger les membres',
+ 'members.added': 'ajouté',
+ 'members.addError': 'Échec de l\'ajout',
+ 'members.removed': 'Membre retiré',
+ 'members.removeError': 'Échec de la suppression',
+
+ // Categories (Admin)
+ 'categories.title': 'Catégories',
+ 'categories.subtitle': 'Gérer les catégories de lieux',
+ 'categories.new': 'Nouvelle catégorie',
+ 'categories.empty': 'Aucune catégorie',
+ 'categories.namePlaceholder': 'Nom de la catégorie',
+ 'categories.icon': 'Icône',
+ 'categories.color': 'Couleur',
+ 'categories.customColor': 'Choisir une couleur personnalisée',
+ 'categories.preview': 'Aperçu',
+ 'categories.defaultName': 'Catégorie',
+ 'categories.update': 'Mettre à jour',
+ 'categories.create': 'Créer',
+ 'categories.confirm.delete': 'Supprimer la catégorie ? Les lieux de cette catégorie ne seront pas supprimés.',
+ 'categories.toast.loadError': 'Impossible de charger les catégories',
+ 'categories.toast.nameRequired': 'Veuillez saisir un nom',
+ 'categories.toast.updated': 'Catégorie mise à jour',
+ 'categories.toast.created': 'Catégorie créée',
+ 'categories.toast.saveError': 'Échec de l\'enregistrement',
+ 'categories.toast.deleted': 'Catégorie supprimée',
+ 'categories.toast.deleteError': 'Échec de la suppression',
+
+ // Backup (Admin)
+ 'backup.title': 'Sauvegarde des données',
+ 'backup.subtitle': 'Base de données et tous les fichiers téléversés',
+ 'backup.refresh': 'Actualiser',
+ 'backup.upload': 'Téléverser une sauvegarde',
+ 'backup.uploading': 'Téléversement…',
+ 'backup.create': 'Créer une sauvegarde',
+ 'backup.creating': 'Création…',
+ 'backup.empty': 'Aucune sauvegarde',
+ 'backup.createFirst': 'Créer la première sauvegarde',
+ '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.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.deleted': 'Sauvegarde supprimée',
+ 'backup.toast.deleteError': 'Échec de la suppression',
+ 'backup.toast.downloadError': 'Échec du téléchargement',
+ 'backup.toast.settingsSaved': 'Paramètres de sauvegarde automatique enregistrés',
+ 'backup.toast.settingsError': 'Impossible d\'enregistrer les paramètres',
+ 'backup.auto.title': 'Sauvegarde automatique',
+ 'backup.auto.subtitle': 'Sauvegarde automatique programmée',
+ 'backup.auto.enable': 'Activer la sauvegarde automatique',
+ 'backup.auto.enableHint': 'Les sauvegardes seront créées automatiquement selon le calendrier choisi',
+ 'backup.auto.interval': 'Intervalle',
+ 'backup.auto.keepLabel': 'Supprimer les anciennes sauvegardes après',
+ 'backup.interval.hourly': 'Toutes les heures',
+ 'backup.interval.daily': 'Quotidien',
+ 'backup.interval.weekly': 'Hebdomadaire',
+ 'backup.interval.monthly': 'Mensuel',
+ 'backup.keep.1day': '1 jour',
+ 'backup.keep.3days': '3 jours',
+ 'backup.keep.7days': '7 jours',
+ 'backup.keep.14days': '14 jours',
+ 'backup.keep.30days': '30 jours',
+ 'backup.keep.forever': 'Conserver indéfiniment',
+
+ // Photos
+ 'photos.allDays': 'Tous les jours',
+ 'photos.noPhotos': 'Aucune photo',
+ 'photos.uploadHint': 'Téléversez 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',
+
+ // 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.restoreTip': 'Conseil : créez une sauvegarde de l\'état actuel avant de restaurer.',
+ 'backup.restoreConfirm': 'Oui, restaurer',
+
+ // PDF
+ 'pdf.travelPlan': 'Plan de voyage',
+ 'pdf.planned': 'Planifié',
+ 'pdf.costLabel': 'Coût EUR',
+ 'pdf.preview': 'Aperçu PDF',
+ 'pdf.saveAsPdf': 'Enregistrer en PDF',
+
+ // Planner
+ 'planner.places': 'Lieux',
+ 'planner.bookings': 'Réservations',
+ 'planner.packingList': 'Liste de bagages',
+ 'planner.documents': 'Documents',
+ 'planner.dayPlan': 'Plan du jour',
+ 'planner.reservations': 'Réservations',
+ 'planner.minTwoPlaces': 'Au moins 2 lieux avec coordonnées nécessaires',
+ 'planner.noGeoPlaces': 'Aucun lieu avec coordonnées disponible',
+ 'planner.routeCalculated': 'Itinéraire calculé',
+ 'planner.routeCalcFailed': 'L\'itinéraire n\'a pas pu être calculé',
+ 'planner.routeError': 'Erreur lors du calcul de l\'itinéraire',
+ 'planner.routeOptimized': 'Itinéraire optimisé',
+ 'planner.reservationUpdated': 'Réservation mise à jour',
+ 'planner.reservationAdded': 'Réservation ajoutée',
+ 'planner.confirmDeleteReservation': 'Supprimer la réservation ?',
+ 'planner.reservationDeleted': 'Réservation supprimée',
+ 'planner.days': 'Jours',
+ 'planner.allPlaces': 'Tous les lieux',
+ 'planner.totalPlaces': '{n} lieux au total',
+ 'planner.noDaysPlanned': 'Aucun jour planifié',
+ 'planner.editTrip': 'Modifier le voyage \u2192',
+ 'planner.placeOne': '1 lieu',
+ '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.resPending': 'Réservation en attente · ',
+ 'planner.resConfirmed': 'Réservation confirmée · ',
+ 'planner.notePlaceholder': 'Note…',
+ 'planner.noteTimePlaceholder': 'Heure (facultatif)',
+ 'planner.noteExamplePlaceholder': 'ex. S3 à 14h30 depuis la gare centrale, ferry depuis le quai 7, pause déjeuner…',
+ 'planner.totalCost': 'Coût total',
+ 'planner.searchPlaces': 'Rechercher des lieux…',
+ 'planner.allCategories': 'Toutes les catégories',
+ 'planner.noPlacesFound': 'Aucun lieu trouvé',
+ 'planner.addFirstPlace': 'Ajouter un premier lieu',
+ 'planner.noReservations': 'Aucune réservation',
+ 'planner.addFirstReservation': 'Ajouter une première réservation',
+ 'planner.new': 'Nouveau',
+ 'planner.addToDay': '+ Jour',
+ 'planner.calculating': 'Calcul…',
+ 'planner.route': 'Itinéraire',
+ 'planner.optimize': 'Optimiser',
+ 'planner.openGoogleMaps': 'Ouvrir dans Google Maps',
+ 'planner.selectDayHint': 'Sélectionnez un jour dans la liste de gauche pour voir le plan du jour',
+ 'planner.noPlacesForDay': 'Aucun lieu pour ce jour',
+ 'planner.addPlacesLink': 'Ajouter des lieux \u2192',
+ 'planner.minTotal': 'min. total',
+ 'planner.noReservation': 'Pas de réservation',
+ 'planner.removeFromDay': 'Retirer du jour',
+ 'planner.addToThisDay': 'Ajouter au jour',
+ 'planner.overview': 'Aperçu',
+ 'planner.noDays': 'Aucun jour',
+ 'planner.editTripToAddDays': 'Modifiez le voyage pour ajouter des jours',
+ 'planner.dayCount': '{n} jours',
+ 'planner.clickToUnlock': 'Cliquer 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}',
+
+ // Dashboard Stats
+ 'stats.countries': 'Pays',
+ 'stats.cities': 'Villes',
+ 'stats.trips': 'Voyages',
+ 'stats.places': 'Lieux',
+ 'stats.worldProgress': 'Progression mondiale',
+ 'stats.visited': 'visités',
+ 'stats.remaining': 'restants',
+ 'stats.visitedCountries': 'Pays visités',
+
+ // Day Detail Panel
+ 'day.precipProb': 'Probabilité de pluie',
+ 'day.precipitation': 'Précipitations',
+ 'day.wind': 'Vent',
+ 'day.sunrise': 'Lever du soleil',
+ 'day.sunset': 'Coucher du soleil',
+ 'day.hourlyForecast': 'Prévisions horaires',
+ 'day.climateHint': 'Moyennes historiques — prévisions réelles disponibles dans les 16 jours précédant cette date.',
+ 'day.noWeather': 'Aucune donnée météo disponible. Ajoutez un lieu avec des coordonnées.',
+ 'day.overview': 'Aperçu du jour',
+ 'day.accommodation': 'Hébergement',
+ 'day.addAccommodation': 'Ajouter un hébergement',
+ 'day.hotelDayRange': 'Appliquer aux jours',
+ 'day.noPlacesForHotel': 'Ajoutez d\'abord des lieux à votre voyage',
+ 'day.allDays': 'Tous',
+ 'day.checkIn': 'Arrivée',
+ 'day.checkOut': 'Départ',
+ 'day.confirmation': 'Confirmation',
+ 'day.editAccommodation': 'Modifier l\'hébergement',
+ 'day.reservations': 'Réservations',
+
+ // Collab Addon
+ 'collab.tabs.chat': 'Chat',
+ 'collab.tabs.notes': 'Notes',
+ 'collab.tabs.polls': 'Sondages',
+ 'collab.whatsNext.title': 'À venir',
+ 'collab.whatsNext.today': 'Aujourd\'hui',
+ 'collab.whatsNext.tomorrow': 'Demain',
+ 'collab.whatsNext.empty': 'Aucune activité à venir',
+ '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.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',
+ 'collab.chat.today': 'Aujourd\'hui',
+ 'collab.chat.yesterday': 'Hier',
+ 'collab.chat.deletedMessage': 'a supprimé un message',
+ 'collab.chat.loadMore': 'Charger les messages précédents',
+ 'collab.chat.justNow': 'à l\'instant',
+ 'collab.chat.minutesAgo': 'il y a {n} min',
+ 'collab.chat.hoursAgo': 'il y a {n} h',
+ 'collab.notes.title': 'Notes',
+ 'collab.notes.new': 'Nouvelle note',
+ 'collab.notes.empty': 'Aucune note',
+ '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.categoryPlaceholder': 'Catégorie',
+ 'collab.notes.newCategory': 'Nouvelle catégorie...',
+ 'collab.notes.category': 'Catégorie',
+ 'collab.notes.noCategory': 'Sans catégorie',
+ 'collab.notes.color': 'Couleur',
+ 'collab.notes.save': 'Enregistrer',
+ 'collab.notes.cancel': 'Annuler',
+ 'collab.notes.edit': 'Modifier',
+ 'collab.notes.delete': 'Supprimer',
+ 'collab.notes.pin': 'Épingler',
+ 'collab.notes.unpin': 'Désépingler',
+ 'collab.notes.daysAgo': 'il y a {n} j',
+ 'collab.notes.categorySettings': 'Gérer les catégories',
+ 'collab.notes.create': 'Créer',
+ 'collab.notes.website': 'Site web',
+ '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',
+ 'collab.polls.title': 'Sondages',
+ 'collab.polls.new': 'Nouveau sondage',
+ 'collab.polls.empty': 'Aucun sondage',
+ 'collab.polls.emptyHint': 'Posez des questions au groupe et votez ensemble',
+ 'collab.polls.question': 'Question',
+ 'collab.polls.questionPlaceholder': 'Que devrait-on faire ?',
+ 'collab.polls.addOption': '+ Ajouter une option',
+ 'collab.polls.optionPlaceholder': 'Option {n}',
+ 'collab.polls.create': 'Créer le sondage',
+ 'collab.polls.close': 'Fermer',
+ 'collab.polls.closed': 'Fermé',
+ 'collab.polls.votes': '{n} votes',
+ 'collab.polls.vote': '{n} vote',
+ 'collab.polls.multipleChoice': 'Choix multiples',
+ 'collab.polls.multiChoice': 'Choix multiples',
+ 'collab.polls.deadline': 'Date limite',
+ 'collab.polls.option': 'Option',
+ 'collab.polls.options': 'Options',
+ 'collab.polls.delete': 'Supprimer',
+ 'collab.polls.closedSection': 'Fermés',
+}
+
+export default fr
diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts
new file mode 100644
index 0000000..42e8182
--- /dev/null
+++ b/client/src/i18n/translations/nl.ts
@@ -0,0 +1,1082 @@
+const nl: Record = {
+ // Common
+ 'common.save': 'Opslaan',
+ 'common.cancel': 'Annuleren',
+ 'common.delete': 'Verwijderen',
+ 'common.edit': 'Bewerken',
+ 'common.add': 'Toevoegen',
+ 'common.loading': 'Laden...',
+ 'common.error': 'Fout',
+ 'common.back': 'Terug',
+ 'common.all': 'Alles',
+ 'common.close': 'Sluiten',
+ 'common.open': 'Openen',
+ 'common.upload': 'Uploaden',
+ 'common.search': 'Zoeken',
+ 'common.confirm': 'Bevestigen',
+ 'common.ok': 'OK',
+ 'common.yes': 'Ja',
+ 'common.no': 'Nee',
+ 'common.or': 'of',
+ 'common.none': 'Geen',
+ 'common.date': 'Datum',
+ 'common.rename': 'Hernoemen',
+ 'common.name': 'Naam',
+ 'common.email': 'E-mail',
+ 'common.password': 'Wachtwoord',
+ 'common.saving': 'Opslaan...',
+ 'common.update': 'Bijwerken',
+ 'common.change': 'Wijzigen',
+ 'common.uploading': 'Uploaden…',
+ 'common.backToPlanning': 'Terug naar planning',
+ 'common.reset': 'Resetten',
+
+ // Navbar
+ 'nav.trip': 'Reis',
+ 'nav.share': 'Delen',
+ 'nav.settings': 'Instellingen',
+ 'nav.admin': 'Admin',
+ 'nav.logout': 'Uitloggen',
+ 'nav.lightMode': 'Lichte modus',
+ 'nav.darkMode': 'Donkere modus',
+ 'nav.autoMode': 'Automatisch',
+ 'nav.administrator': 'Beheerder',
+
+ // Dashboard
+ 'dashboard.title': 'Mijn reizen',
+ 'dashboard.subtitle.loading': 'Reizen laden...',
+ 'dashboard.subtitle.trips': '{count} reizen ({archived} gearchiveerd)',
+ 'dashboard.subtitle.empty': 'Begin je eerste reis',
+ 'dashboard.subtitle.activeOne': '{count} actieve reis',
+ 'dashboard.subtitle.activeMany': '{count} actieve reizen',
+ 'dashboard.subtitle.archivedSuffix': ' · {count} gearchiveerd',
+ 'dashboard.newTrip': 'Nieuwe reis',
+ 'dashboard.currency': 'Valuta',
+ 'dashboard.timezone': 'Tijdzones',
+ 'dashboard.localTime': 'Lokaal',
+ 'dashboard.timezoneCustomTitle': 'Aangepaste tijdzone',
+ 'dashboard.timezoneCustomLabelPlaceholder': 'Label (optioneel)',
+ 'dashboard.timezoneCustomTzPlaceholder': 'bijv. America/New_York',
+ 'dashboard.timezoneCustomAdd': 'Toevoegen',
+ 'dashboard.timezoneCustomErrorEmpty': 'Voer een tijdzone-identificatie in',
+ 'dashboard.timezoneCustomErrorInvalid': 'Ongeldige tijdzone. Gebruik een formaat zoals Europe/Berlin',
+ 'dashboard.timezoneCustomErrorDuplicate': 'Al toegevoegd',
+ 'dashboard.emptyTitle': 'Nog geen reizen',
+ 'dashboard.emptyText': 'Maak je eerste reis aan en begin met plannen!',
+ 'dashboard.emptyButton': 'Eerste reis aanmaken',
+ 'dashboard.nextTrip': 'Volgende reis',
+ 'dashboard.shared': 'Gedeeld',
+ 'dashboard.sharedBy': 'Gedeeld door {name}',
+ 'dashboard.days': 'Dagen',
+ 'dashboard.places': 'Plaatsen',
+ 'dashboard.archive': 'Archiveren',
+ 'dashboard.restore': 'Herstellen',
+ 'dashboard.archived': 'Gearchiveerd',
+ 'dashboard.status.ongoing': 'Lopend',
+ 'dashboard.status.today': 'Vandaag',
+ 'dashboard.status.tomorrow': 'Morgen',
+ 'dashboard.status.past': 'Afgelopen',
+ 'dashboard.status.daysLeft': 'nog {count} dagen',
+ 'dashboard.toast.loadError': 'Reizen laden mislukt',
+ 'dashboard.toast.created': 'Reis aangemaakt!',
+ 'dashboard.toast.createError': 'Reis aanmaken mislukt',
+ 'dashboard.toast.updated': 'Reis bijgewerkt!',
+ 'dashboard.toast.updateError': 'Reis bijwerken mislukt',
+ 'dashboard.toast.deleted': 'Reis verwijderd',
+ 'dashboard.toast.deleteError': 'Reis verwijderen mislukt',
+ 'dashboard.toast.archived': 'Reis gearchiveerd',
+ 'dashboard.toast.archiveError': 'Reis archiveren mislukt',
+ 'dashboard.toast.restored': 'Reis hersteld',
+ 'dashboard.toast.restoreError': 'Reis herstellen mislukt',
+ 'dashboard.confirm.delete': 'Reis "{title}" verwijderen? Alle plaatsen en plannen worden permanent verwijderd.',
+ 'dashboard.editTrip': 'Reis bewerken',
+ 'dashboard.createTrip': 'Nieuwe reis aanmaken',
+ 'dashboard.tripTitle': 'Titel',
+ 'dashboard.tripTitlePlaceholder': 'bijv. Zomer in Japan',
+ 'dashboard.tripDescription': 'Beschrijving',
+ 'dashboard.tripDescriptionPlaceholder': 'Waar gaat deze reis over?',
+ 'dashboard.startDate': 'Startdatum',
+ 'dashboard.endDate': 'Einddatum',
+ 'dashboard.noDateHint': 'Geen datum ingesteld — er worden standaard 7 dagen aangemaakt. Je kunt dit altijd wijzigen.',
+ 'dashboard.coverImage': 'Omslagafbeelding',
+ 'dashboard.addCoverImage': 'Omslagafbeelding toevoegen',
+ 'dashboard.coverSaved': 'Omslagafbeelding opgeslagen',
+ 'dashboard.coverUploadError': 'Uploaden mislukt',
+ 'dashboard.coverRemoveError': 'Verwijderen mislukt',
+ 'dashboard.titleRequired': 'Titel is verplicht',
+ 'dashboard.endDateError': 'Einddatum moet na de startdatum liggen',
+
+ // Settings
+ 'settings.title': 'Instellingen',
+ 'settings.subtitle': 'Configureer je persoonlijke instellingen',
+ 'settings.map': 'Kaart',
+ 'settings.mapTemplate': 'Kaartsjabloon',
+ 'settings.mapTemplatePlaceholder.select': 'Selecteer sjabloon...',
+ 'settings.mapDefaultHint': 'Laat leeg voor OpenStreetMap (standaard)',
+ 'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
+ 'settings.mapHint': 'URL-sjabloon voor kaarttegels',
+ 'settings.latitude': 'Breedtegraad',
+ 'settings.longitude': 'Lengtegraad',
+ 'settings.saveMap': 'Kaart opslaan',
+ 'settings.apiKeys': 'API-sleutels',
+ 'settings.mapsKey': 'Google Maps API-sleutel',
+ 'settings.mapsKeyHint': 'Voor plaatsen zoeken. Vereist Places API (New). Verkrijgbaar op console.cloud.google.com',
+ 'settings.weatherKey': 'OpenWeatherMap API-sleutel',
+ 'settings.weatherKeyHint': 'Voor weergegevens. Gratis op openweathermap.org/api',
+ 'settings.keyPlaceholder': 'Sleutel invoeren...',
+ 'settings.configured': 'Geconfigureerd',
+ 'settings.saveKeys': 'Sleutels opslaan',
+ 'settings.display': 'Weergave',
+ 'settings.colorMode': 'Kleurmodus',
+ 'settings.light': 'Licht',
+ 'settings.dark': 'Donker',
+ 'settings.auto': 'Automatisch',
+ 'settings.language': 'Taal',
+ 'settings.temperature': 'Temperatuureenheid',
+ 'settings.timeFormat': 'Tijdnotatie',
+ 'settings.routeCalculation': 'Routeberekening',
+ 'settings.on': 'Aan',
+ 'settings.off': 'Uit',
+ 'settings.account': 'Account',
+ 'settings.username': 'Gebruikersnaam',
+ 'settings.email': 'E-mail',
+ 'settings.role': 'Rol',
+ 'settings.roleAdmin': 'Beheerder',
+ 'settings.oidcLinked': 'Gekoppeld met',
+ 'settings.changePassword': 'Wachtwoord wijzigen',
+ 'settings.currentPassword': 'Huidig wachtwoord',
+ 'settings.currentPasswordRequired': 'Huidig wachtwoord is verplicht',
+ 'settings.newPassword': 'Nieuw wachtwoord',
+ 'settings.confirmPassword': 'Bevestig nieuw wachtwoord',
+ 'settings.updatePassword': 'Wachtwoord bijwerken',
+ 'settings.passwordRequired': 'Voer het huidige en nieuwe wachtwoord in',
+ 'settings.passwordTooShort': 'Wachtwoord moet minimaal 8 tekens bevatten',
+ 'settings.passwordMismatch': 'Wachtwoorden komen niet overeen',
+ 'settings.passwordWeak': 'Wachtwoord moet hoofdletters, kleine letters en een cijfer bevatten',
+ 'settings.passwordChanged': 'Wachtwoord succesvol gewijzigd',
+ 'settings.deleteAccount': 'Account verwijderen',
+ 'settings.deleteAccountTitle': 'Account verwijderen?',
+ 'settings.deleteAccountWarning': 'Je account en al je reizen, plaatsen en bestanden worden permanent verwijderd. Deze actie kan niet ongedaan worden gemaakt.',
+ 'settings.deleteAccountConfirm': 'Permanent verwijderen',
+ 'settings.deleteBlockedTitle': 'Verwijderen niet mogelijk',
+ 'settings.deleteBlockedMessage': 'Je bent de enige beheerder. Maak eerst een andere gebruiker beheerder voordat je je account verwijdert.',
+ 'settings.roleUser': 'Gebruiker',
+ 'settings.saveProfile': 'Profiel opslaan',
+ 'settings.toast.mapSaved': 'Kaartinstellingen opgeslagen',
+ 'settings.toast.keysSaved': 'API-sleutels opgeslagen',
+ 'settings.toast.displaySaved': 'Weergave-instellingen opgeslagen',
+ 'settings.toast.profileSaved': 'Profiel opgeslagen',
+ 'settings.uploadAvatar': 'Profielfoto uploaden',
+ 'settings.removeAvatar': 'Profielfoto verwijderen',
+ 'settings.avatarUploaded': 'Profielfoto bijgewerkt',
+ 'settings.avatarRemoved': 'Profielfoto verwijderd',
+ 'settings.avatarError': 'Uploaden mislukt',
+
+ // Login
+ 'login.error': 'Inloggen mislukt. Controleer je inloggegevens.',
+ 'login.tagline': 'Jouw reizen.\nJouw plan.',
+ 'login.description': 'Plan reizen samen met interactieve kaarten, budgetten en realtime synchronisatie.',
+ 'login.features.maps': 'Interactieve kaarten',
+ 'login.features.mapsDesc': 'Google Places, routes en clustering',
+ 'login.features.realtime': 'Realtime synchronisatie',
+ 'login.features.realtimeDesc': 'Plan samen via WebSocket',
+ 'login.features.budget': 'Budgetbeheer',
+ 'login.features.budgetDesc': 'Categorieën, grafieken en kosten per persoon',
+ 'login.features.collab': 'Samenwerking',
+ 'login.features.collabDesc': 'Meerdere gebruikers met gedeelde reizen',
+ 'login.features.packing': 'Paklijsten',
+ 'login.features.packingDesc': 'Categorieën, voortgang en suggesties',
+ 'login.features.bookings': 'Reserveringen',
+ 'login.features.bookingsDesc': 'Vluchten, hotels, restaurants en meer',
+ 'login.features.files': 'Documenten',
+ 'login.features.filesDesc': 'Upload en beheer documenten',
+ 'login.features.routes': 'Slimme routes',
+ 'login.features.routesDesc': 'Automatisch optimaliseren en exporteren naar Google Maps',
+ 'login.selfHosted': 'Zelf gehost · Open Source · Jouw gegevens blijven van jou',
+ 'login.title': 'Inloggen',
+ 'login.subtitle': 'Welkom terug',
+ 'login.signingIn': 'Inloggen…',
+ 'login.signIn': 'Inloggen',
+ 'login.createAdmin': 'Beheerdersaccount aanmaken',
+ 'login.createAdminHint': 'Stel het eerste beheerdersaccount in voor TREK.',
+ 'login.createAccount': 'Account aanmaken',
+ 'login.createAccountHint': 'Registreer een nieuw account.',
+ 'login.creating': 'Aanmaken…',
+ 'login.noAccount': 'Nog geen account?',
+ 'login.hasAccount': 'Heb je al een account?',
+ 'login.register': 'Registreren',
+ 'login.emailPlaceholder': 'jouw@email.com',
+ 'login.username': 'Gebruikersnaam',
+ 'login.oidc.registrationDisabled': 'Registratie is uitgeschakeld. Neem contact op met je beheerder.',
+ 'login.oidc.noEmail': 'Geen e-mailadres ontvangen van de provider.',
+ 'login.oidc.tokenFailed': 'Authenticatie mislukt.',
+ 'login.oidc.invalidState': 'Ongeldige sessie. Probeer het opnieuw.',
+ 'login.demoFailed': 'Demo-login mislukt',
+ 'login.oidcSignIn': 'Inloggen met {name}',
+ 'login.oidcOnly': 'Wachtwoordauthenticatie is uitgeschakeld. Log in via je SSO-provider.',
+ 'login.demoHint': 'Probeer de demo — geen registratie nodig',
+
+ // Register
+ 'register.passwordMismatch': 'Wachtwoorden komen niet overeen',
+ 'register.passwordTooShort': 'Wachtwoord moet minimaal 6 tekens bevatten',
+ 'register.failed': 'Registratie mislukt',
+ 'register.getStarted': 'Aan de slag',
+ 'register.subtitle': 'Maak een account aan en begin met het plannen van je droomreizen.',
+ 'register.feature1': 'Onbeperkte reisplannen',
+ 'register.feature2': 'Interactieve kaartweergave',
+ 'register.feature3': 'Beheer plaatsen en categorieën',
+ 'register.feature4': 'Houd reserveringen bij',
+ 'register.feature5': 'Maak paklijsten',
+ 'register.feature6': 'Bewaar foto\'s en bestanden',
+ 'register.createAccount': 'Account aanmaken',
+ 'register.startPlanning': 'Begin met het plannen van je reis',
+ 'register.minChars': 'Min. 6 tekens',
+ 'register.confirmPassword': 'Bevestig wachtwoord',
+ 'register.repeatPassword': 'Herhaal wachtwoord',
+ 'register.registering': 'Registreren...',
+ 'register.register': 'Registreren',
+ 'register.hasAccount': 'Heb je al een account?',
+ 'register.signIn': 'Inloggen',
+
+ // Admin
+ 'admin.title': 'Beheer',
+ 'admin.subtitle': 'Gebruikersbeheer en systeeminstellingen',
+ 'admin.tabs.users': 'Gebruikers',
+ 'admin.tabs.categories': 'Categorieën',
+ 'admin.tabs.backup': 'Back-up',
+ 'admin.stats.users': 'Gebruikers',
+ 'admin.stats.trips': 'Reizen',
+ 'admin.stats.places': 'Plaatsen',
+ 'admin.stats.photos': 'Foto\'s',
+ 'admin.stats.files': 'Bestanden',
+ 'admin.table.user': 'Gebruiker',
+ 'admin.table.email': 'E-mail',
+ 'admin.table.role': 'Rol',
+ 'admin.table.created': 'Aangemaakt',
+ 'admin.table.lastLogin': 'Laatste login',
+ 'admin.table.actions': 'Acties',
+ 'admin.you': '(Jij)',
+ 'admin.editUser': 'Gebruiker bewerken',
+ 'admin.newPassword': 'Nieuw wachtwoord',
+ 'admin.newPasswordHint': 'Laat leeg om het huidige wachtwoord te behouden',
+ 'admin.deleteUser': 'Gebruiker "{name}" verwijderen? Alle reizen worden permanent verwijderd.',
+ 'admin.deleteUserTitle': 'Gebruiker verwijderen',
+ 'admin.newPasswordPlaceholder': 'Nieuw wachtwoord invoeren…',
+ 'admin.toast.loadError': 'Beheergegevens laden mislukt',
+ 'admin.toast.userUpdated': 'Gebruiker bijgewerkt',
+ 'admin.toast.updateError': 'Bijwerken mislukt',
+ 'admin.toast.userDeleted': 'Gebruiker verwijderd',
+ 'admin.toast.deleteError': 'Verwijderen mislukt',
+ 'admin.toast.cannotDeleteSelf': 'Je kunt je eigen account niet verwijderen',
+ 'admin.toast.userCreated': 'Gebruiker aangemaakt',
+ 'admin.toast.createError': 'Gebruiker aanmaken mislukt',
+ 'admin.toast.fieldsRequired': 'Gebruikersnaam, e-mail en wachtwoord zijn verplicht',
+ 'admin.createUser': 'Gebruiker aanmaken',
+ 'admin.tabs.settings': 'Instellingen',
+ 'admin.allowRegistration': 'Registratie toestaan',
+ 'admin.allowRegistrationHint': 'Nieuwe gebruikers kunnen zichzelf registreren',
+ 'admin.apiKeys': 'API-sleutels',
+ 'admin.apiKeysHint': 'Optioneel. Schakelt uitgebreide plaatsgegevens in zoals foto\'s en weer.',
+ 'admin.mapsKey': 'Google Maps API-sleutel',
+ 'admin.mapsKeyHint': 'Vereist voor het zoeken van plaatsen. Verkrijgbaar op console.cloud.google.com',
+ 'admin.mapsKeyHintLong': 'Zonder API-sleutel wordt OpenStreetMap gebruikt voor het zoeken van plaatsen. Met een Google API-sleutel kunnen ook foto\'s, beoordelingen en openingstijden worden geladen. Verkrijgbaar op console.cloud.google.com.',
+ 'admin.recommended': 'Aanbevolen',
+ 'admin.weatherKey': 'OpenWeatherMap API-sleutel',
+ 'admin.weatherKeyHint': 'Voor weergegevens. Gratis op openweathermap.org',
+ 'admin.validateKey': 'Testen',
+ 'admin.keyValid': 'Verbonden',
+ 'admin.keyInvalid': 'Ongeldig',
+ 'admin.keySaved': 'API-sleutels opgeslagen',
+ 'admin.oidcTitle': 'Single Sign-On (OIDC)',
+ 'admin.oidcSubtitle': 'Sta inloggen toe via externe providers zoals Google, Apple, Authentik of Keycloak.',
+ 'admin.oidcDisplayName': 'Weergavenaam',
+ 'admin.oidcIssuer': 'Issuer-URL',
+ 'admin.oidcIssuerHint': 'De OpenID Connect Issuer-URL van de provider. Bijv. https://accounts.google.com',
+ 'admin.oidcSaved': 'OIDC-configuratie opgeslagen',
+ 'admin.oidcOnlyMode': 'Wachtwoordauthenticatie uitschakelen',
+ 'admin.oidcOnlyModeHint': 'Indien ingeschakeld, is alleen SSO-login toegestaan. Inloggen en registreren met wachtwoord worden geblokkeerd.',
+
+ // File Types
+ 'admin.fileTypes': 'Toegestane bestandstypen',
+ 'admin.fileTypesHint': 'Configureer welke bestandstypen gebruikers kunnen uploaden.',
+ 'admin.fileTypesFormat': 'Kommagescheiden extensies (bijv. jpg,png,pdf,doc). Gebruik * om alle typen toe te staan.',
+ 'admin.fileTypesSaved': 'Bestandstype-instellingen opgeslagen',
+
+ // Addons
+ 'admin.tabs.addons': 'Add-ons',
+ 'admin.addons.title': 'Add-ons',
+ 'admin.addons.subtitle': 'Schakel functies in of uit om je TREK-ervaring aan te passen.',
+ 'admin.addons.catalog.memories.name': 'Herinneringen',
+ 'admin.addons.catalog.memories.description': 'Gedeelde fotoalbums voor elke reis',
+ 'admin.addons.catalog.packing.name': 'Inpakken',
+ 'admin.addons.catalog.packing.description': 'Checklists om je bagage voor elke reis voor te bereiden',
+ 'admin.addons.catalog.budget.name': 'Budget',
+ 'admin.addons.catalog.budget.description': 'Houd uitgaven bij en plan je reisbudget',
+ 'admin.addons.catalog.documents.name': 'Documenten',
+ 'admin.addons.catalog.documents.description': 'Bewaar en beheer reisdocumenten',
+ 'admin.addons.catalog.vacay.name': 'Vacay',
+ 'admin.addons.catalog.vacay.description': 'Persoonlijke vakantieplanner met kalenderweergave',
+ 'admin.addons.catalog.atlas.name': 'Atlas',
+ 'admin.addons.catalog.atlas.description': 'Wereldkaart met bezochte landen en reisstatistieken',
+ 'admin.addons.catalog.collab.name': 'Collab',
+ 'admin.addons.catalog.collab.description': 'Realtime notities, polls en chat voor het plannen van reizen',
+ 'admin.addons.subtitleBefore': 'Schakel functies in of uit om je ',
+ 'admin.addons.subtitleAfter': '-ervaring aan te passen.',
+ 'admin.addons.enabled': 'Ingeschakeld',
+ 'admin.addons.disabled': 'Uitgeschakeld',
+ 'admin.addons.type.trip': 'Reis',
+ 'admin.addons.type.global': 'Globaal',
+ 'admin.addons.tripHint': 'Beschikbaar als tabblad binnen elke reis',
+ 'admin.addons.globalHint': 'Beschikbaar als zelfstandig onderdeel in de hoofdnavigatie',
+ 'admin.addons.toast.updated': 'Add-on bijgewerkt',
+ 'admin.addons.toast.error': 'Add-on bijwerken mislukt',
+ 'admin.addons.noAddons': 'Geen add-ons beschikbaar',
+ // Weather info
+ 'admin.weather.title': 'Weergegevens',
+ 'admin.weather.badge': 'Sinds 24 maart 2026',
+ 'admin.weather.description': 'TREK gebruikt Open-Meteo als weerbron. Open-Meteo is een gratis, open-source weerdienst — geen API-sleutel vereist.',
+ 'admin.weather.forecast': '16-daagse voorspelling',
+ 'admin.weather.forecastDesc': 'Voorheen 5 dagen (OpenWeatherMap)',
+ 'admin.weather.climate': 'Historische klimaatgegevens',
+ 'admin.weather.climateDesc': 'Gemiddelden over de afgelopen 85 jaar voor dagen buiten de 16-daagse voorspelling',
+ 'admin.weather.requests': '10.000 verzoeken / dag',
+ 'admin.weather.requestsDesc': 'Gratis, geen API-sleutel vereist',
+ 'admin.weather.locationHint': 'Het weer is gebaseerd op de eerste plaats met coördinaten op elke dag. Als er geen plaats aan een dag is toegewezen, wordt een plaats uit de lijst als referentie gebruikt.',
+
+ // GitHub
+ 'admin.tabs.github': 'GitHub',
+ 'admin.github.title': 'Release-geschiedenis',
+ 'admin.github.subtitle': 'Laatste updates van {repo}',
+ 'admin.github.latest': 'Nieuwste',
+ 'admin.github.prerelease': 'Pre-release',
+ 'admin.github.showDetails': 'Details tonen',
+ 'admin.github.hideDetails': 'Details verbergen',
+ 'admin.github.loadMore': 'Meer laden',
+ 'admin.github.loading': 'Laden...',
+ 'admin.github.error': 'Releases laden mislukt',
+ 'admin.github.by': 'door',
+
+ 'admin.update.available': 'Update beschikbaar',
+ 'admin.update.text': 'TREK {version} is beschikbaar. Je draait {current}.',
+ 'admin.update.button': 'Bekijk op GitHub',
+ 'admin.update.install': 'Update installeren',
+ 'admin.update.confirmTitle': 'Update installeren?',
+ 'admin.update.confirmText': 'TREK wordt bijgewerkt van {current} naar {version}. De server herstart automatisch.',
+ 'admin.update.dataInfo': 'Al je gegevens (reizen, gebruikers, API-sleutels, uploads, Vacay, Atlas, budgetten) worden bewaard.',
+ 'admin.update.warning': 'De app is kort niet beschikbaar tijdens het herstarten.',
+ 'admin.update.confirm': 'Nu bijwerken',
+ 'admin.update.installing': 'Bijwerken…',
+ 'admin.update.success': 'Update geïnstalleerd! Server herstart…',
+ 'admin.update.failed': 'Update mislukt',
+ 'admin.update.backupHint': 'We raden aan een back-up te maken voordat je bijwerkt.',
+ 'admin.update.backupLink': 'Naar back-up',
+ 'admin.update.howTo': 'Hoe bij te werken',
+ 'admin.update.dockerText': 'Je TREK-instantie draait in Docker. Om bij te werken naar {version}, voer de volgende commando\'s uit op je server:',
+ 'admin.update.reloadHint': 'Herlaad de pagina over een paar seconden.',
+
+ // Vacay addon
+ 'vacay.subtitle': 'Plan en beheer vakantiedagen',
+ 'vacay.settings': 'Instellingen',
+ 'vacay.year': 'Jaar',
+ 'vacay.addYear': 'Jaar toevoegen',
+ 'vacay.removeYear': 'Jaar verwijderen',
+ 'vacay.removeYearConfirm': '{year} verwijderen?',
+ 'vacay.removeYearHint': 'Alle vakantie-invoeren en bedrijfsvakanties voor dit jaar worden permanent verwijderd.',
+ 'vacay.remove': 'Verwijderen',
+ 'vacay.persons': 'Personen',
+ 'vacay.noPersons': 'Geen personen toegevoegd',
+ 'vacay.addPerson': 'Persoon toevoegen',
+ 'vacay.editPerson': 'Persoon bewerken',
+ 'vacay.removePerson': 'Persoon verwijderen',
+ 'vacay.removePersonConfirm': '{name} verwijderen?',
+ 'vacay.removePersonHint': 'Alle vakantie-invoeren voor deze persoon worden permanent verwijderd.',
+ 'vacay.personName': 'Naam',
+ 'vacay.personNamePlaceholder': 'Naam invoeren',
+ 'vacay.color': 'Kleur',
+ 'vacay.add': 'Toevoegen',
+ 'vacay.legend': 'Legenda',
+ 'vacay.publicHoliday': 'Feestdag',
+ 'vacay.companyHoliday': 'Bedrijfsvakantie',
+ 'vacay.weekend': 'Weekend',
+ 'vacay.modeVacation': 'Vakantie',
+ 'vacay.modeCompany': 'Bedrijfsvakantie',
+ 'vacay.entitlement': 'Recht',
+ 'vacay.entitlementDays': 'Dagen',
+ 'vacay.used': 'Gebruikt',
+ 'vacay.remaining': 'Resterend',
+ 'vacay.carriedOver': 'van {year}',
+ 'vacay.blockWeekends': 'Weekenden blokkeren',
+ 'vacay.blockWeekendsHint': 'Voorkom vakantie-invoeren op zaterdag en zondag',
+ 'vacay.publicHolidays': 'Feestdagen',
+ 'vacay.publicHolidaysHint': 'Markeer feestdagen in de kalender',
+ 'vacay.selectCountry': 'Selecteer land',
+ 'vacay.selectRegion': 'Selecteer regio (optioneel)',
+ 'vacay.companyHolidays': 'Bedrijfsvakanties',
+ 'vacay.companyHolidaysHint': 'Sta het markeren van bedrijfsbrede vakantiedagen toe',
+ 'vacay.companyHolidaysNoDeduct': 'Bedrijfsvakanties worden niet afgetrokken van vakantiedagen.',
+ 'vacay.carryOver': 'Overdracht',
+ 'vacay.carryOverHint': 'Draag resterende vakantiedagen automatisch over naar het volgende jaar',
+ 'vacay.sharing': 'Delen',
+ 'vacay.sharingHint': 'Deel je vakantieplan met andere TREK-gebruikers',
+ 'vacay.owner': 'Eigenaar',
+ 'vacay.shareEmailPlaceholder': 'E-mail van TREK-gebruiker',
+ 'vacay.shareSuccess': 'Plan succesvol gedeeld',
+ 'vacay.shareError': 'Plan delen mislukt',
+ 'vacay.dissolve': 'Fusie opheffen',
+ 'vacay.dissolveHint': 'Kalenders weer scheiden. Je invoeren blijven behouden.',
+ 'vacay.dissolveAction': 'Opheffen',
+ 'vacay.dissolved': 'Kalender gescheiden',
+ 'vacay.fusedWith': 'Gefuseerd met',
+ 'vacay.you': 'jij',
+ 'vacay.noData': 'Geen gegevens',
+ 'vacay.changeColor': 'Kleur wijzigen',
+ 'vacay.inviteUser': 'Gebruiker uitnodigen',
+ 'vacay.inviteHint': 'Nodig een andere TREK-gebruiker uit om een gecombineerde vakantiekalender te delen.',
+ 'vacay.selectUser': 'Selecteer gebruiker',
+ 'vacay.sendInvite': 'Uitnodiging verzenden',
+ 'vacay.inviteSent': 'Uitnodiging verzonden',
+ 'vacay.inviteError': 'Uitnodiging verzenden mislukt',
+ 'vacay.pending': 'in behandeling',
+ 'vacay.noUsersAvailable': 'Geen gebruikers beschikbaar',
+ 'vacay.accept': 'Accepteren',
+ 'vacay.decline': 'Afwijzen',
+ 'vacay.acceptFusion': 'Accepteren en fuseren',
+ 'vacay.inviteTitle': 'Fusieverzoek',
+ 'vacay.inviteWantsToFuse': 'wil een vakantiekalender met je delen.',
+ 'vacay.fuseInfo1': 'Jullie zien allebei alle vakantie-invoeren in één gedeelde kalender.',
+ 'vacay.fuseInfo2': 'Beide partijen kunnen invoeren voor elkaar aanmaken en bewerken.',
+ 'vacay.fuseInfo3': 'Beide partijen kunnen invoeren verwijderen en vakantierechten wijzigen.',
+ 'vacay.fuseInfo4': 'Instellingen zoals feestdagen en bedrijfsvakanties worden gedeeld.',
+ 'vacay.fuseInfo5': 'De fusie kan op elk moment door beide partijen worden opgeheven. Je invoeren blijven behouden.',
+ 'nav.myTrips': 'Mijn reizen',
+
+ // Atlas addon
+ 'atlas.subtitle': 'Je reisvoetafdruk over de wereld',
+ 'atlas.countries': 'Landen',
+ 'atlas.trips': 'Reizen',
+ 'atlas.places': 'Plaatsen',
+ 'atlas.days': 'Dagen',
+ 'atlas.visitedCountries': 'Bezochte landen',
+ 'atlas.cities': 'Steden',
+ 'atlas.noData': 'Nog geen reisgegevens',
+ 'atlas.noDataHint': 'Maak een reis aan en voeg plaatsen toe om je wereldkaart te zien',
+ 'atlas.lastTrip': 'Laatste reis',
+ 'atlas.nextTrip': 'Volgende reis',
+ 'atlas.daysLeft': 'dagen te gaan',
+ 'atlas.streak': 'Reeks',
+ 'atlas.year': 'jaar',
+ 'atlas.years': 'jaar',
+ 'atlas.yearInRow': 'jaar op rij',
+ 'atlas.yearsInRow': 'jaar op rij',
+ 'atlas.tripIn': 'reis in',
+ 'atlas.tripsIn': 'reizen in',
+ 'atlas.since': 'sinds',
+ 'atlas.europe': 'Europa',
+ 'atlas.asia': 'Azië',
+ 'atlas.northAmerica': 'N.-Amerika',
+ 'atlas.southAmerica': 'Z.-Amerika',
+ 'atlas.africa': 'Afrika',
+ 'atlas.oceania': 'Oceanië',
+ 'atlas.other': 'Overig',
+ 'atlas.firstVisit': 'Eerste reis',
+ 'atlas.lastVisitLabel': 'Laatste reis',
+ 'atlas.tripSingular': 'Reis',
+ 'atlas.tripPlural': 'Reizen',
+ 'atlas.placeVisited': 'Bezochte plaats',
+ 'atlas.placesVisited': 'Bezochte plaatsen',
+
+ // Trip Planner
+ 'trip.tabs.plan': 'Plan',
+ 'trip.tabs.reservations': 'Boekingen',
+ 'trip.tabs.reservationsShort': 'Boek',
+ 'trip.tabs.packing': 'Paklijst',
+ 'trip.tabs.packingShort': 'Inpakken',
+ 'trip.tabs.budget': 'Budget',
+ 'trip.tabs.files': 'Bestanden',
+ 'trip.loading': 'Reis laden...',
+ 'trip.mobilePlan': 'Plan',
+ 'trip.mobilePlaces': 'Plaatsen',
+ 'trip.toast.placeUpdated': 'Plaats bijgewerkt',
+ 'trip.toast.placeAdded': 'Plaats toegevoegd',
+ 'trip.toast.placeDeleted': 'Plaats verwijderd',
+ 'trip.toast.selectDay': 'Selecteer eerst een dag',
+ 'trip.toast.assignedToDay': 'Plaats toegewezen aan dag',
+ 'trip.toast.reorderError': 'Herordenen mislukt',
+ 'trip.toast.reservationUpdated': 'Reservering bijgewerkt',
+ 'trip.toast.reservationAdded': 'Reservering toegevoegd',
+ 'trip.toast.deleted': 'Verwijderd',
+ 'trip.confirm.deletePlace': 'Weet je zeker dat je deze plaats wilt verwijderen?',
+
+ // Day Plan Sidebar
+ 'dayplan.emptyDay': 'Geen plaatsen gepland voor deze dag',
+ 'dayplan.addNote': 'Notitie toevoegen',
+ 'dayplan.editNote': 'Notitie bewerken',
+ 'dayplan.noteAdd': 'Notitie toevoegen',
+ 'dayplan.noteEdit': 'Notitie bewerken',
+ 'dayplan.noteTitle': 'Notitie',
+ 'dayplan.noteSubtitle': 'Dagnotitie',
+ 'dayplan.totalCost': 'Totale kosten',
+ 'dayplan.days': 'Dagen',
+ 'dayplan.dayN': 'Dag {n}',
+ 'dayplan.calculating': 'Berekenen...',
+ 'dayplan.route': 'Route',
+ 'dayplan.optimize': 'Optimaliseren',
+ 'dayplan.optimized': 'Route geoptimaliseerd',
+ 'dayplan.routeError': 'Route berekenen mislukt',
+ 'dayplan.toast.needTwoPlaces': 'Minimaal twee plaatsen nodig voor route-optimalisatie',
+ 'dayplan.toast.routeOptimized': 'Route geoptimaliseerd',
+ 'dayplan.toast.noGeoPlaces': 'Geen plaatsen met coördinaten gevonden voor routeberekening',
+ 'dayplan.confirmed': 'Bevestigd',
+ 'dayplan.pendingRes': 'In behandeling',
+ 'dayplan.pdf': 'PDF',
+ 'dayplan.pdfTooltip': 'Dagplan exporteren als PDF',
+ 'dayplan.pdfError': 'PDF-export mislukt',
+
+ // Places Sidebar
+ 'places.addPlace': 'Plaats/activiteit toevoegen',
+ 'places.assignToDay': 'Aan welke dag toevoegen?',
+ 'places.all': 'Alle',
+ 'places.unplanned': 'Ongepland',
+ 'places.search': 'Plaatsen zoeken...',
+ 'places.allCategories': 'Alle categorieën',
+ 'places.count': '{count} plaatsen',
+ 'places.countSingular': '1 plaats',
+ 'places.allPlanned': 'Alle plaatsen zijn gepland',
+ 'places.noneFound': 'Geen plaatsen gevonden',
+ 'places.editPlace': 'Plaats bewerken',
+ 'places.formName': 'Naam',
+ 'places.formNamePlaceholder': 'bijv. Eiffeltoren',
+ 'places.formDescription': 'Beschrijving',
+ 'places.formDescriptionPlaceholder': 'Korte beschrijving...',
+ 'places.formAddress': 'Adres',
+ 'places.formAddressPlaceholder': 'Straat, stad, land',
+ 'places.formLat': 'Breedtegraad (bijv. 48.8566)',
+ 'places.formLng': 'Lengtegraad (bijv. 2.3522)',
+ 'places.formCategory': 'Categorie',
+ 'places.noCategory': 'Geen categorie',
+ 'places.categoryNamePlaceholder': 'Categorienaam',
+ 'places.formTime': 'Tijd',
+ 'places.startTime': 'Start',
+ 'places.endTime': 'Einde',
+ 'places.endTimeBeforeStart': 'Eindtijd is vóór de starttijd',
+ 'places.timeCollision': 'Tijdoverlap met:',
+ 'places.formWebsite': 'Website',
+ 'places.formNotesPlaceholder': 'Persoonlijke notities...',
+ 'places.formReservation': 'Reservering',
+ 'places.reservationNotesPlaceholder': 'Reserveringsnotities, bevestigingsnummer...',
+ 'places.mapsSearchPlaceholder': 'Plaatsen zoeken...',
+ 'places.mapsSearchError': 'Zoeken naar plaatsen mislukt.',
+ 'places.osmHint': 'Zoeken via OpenStreetMap (geen foto\'s, openingstijden of beoordelingen). Voeg een Google API-sleutel toe in instellingen voor volledige details.',
+ 'places.osmActive': 'Zoeken via OpenStreetMap (geen foto\'s, beoordelingen of openingstijden). Voeg een Google API-sleutel toe in Instellingen voor uitgebreide gegevens.',
+ 'places.categoryCreateError': 'Categorie aanmaken mislukt',
+ 'places.nameRequired': 'Voer een naam in',
+ 'places.saveError': 'Opslaan mislukt',
+ // Place Inspector
+ 'inspector.opened': 'Open',
+ 'inspector.closed': 'Gesloten',
+ 'inspector.openingHours': 'Openingstijden',
+ 'inspector.showHours': 'Openingstijden tonen',
+ 'inspector.files': 'Bestanden',
+ 'inspector.filesCount': '{count} bestanden',
+ 'inspector.removeFromDay': 'Verwijderen van dag',
+ 'inspector.addToDay': 'Toevoegen aan dag',
+ 'inspector.confirmedRes': 'Bevestigde reservering',
+ 'inspector.pendingRes': 'Reservering in behandeling',
+ 'inspector.google': 'Openen in Google Maps',
+ 'inspector.website': 'Website openen',
+ 'inspector.addRes': 'Reservering',
+ 'inspector.editRes': 'Reservering bewerken',
+ 'inspector.participants': 'Deelnemers',
+
+ // Reservations
+ 'reservations.title': 'Boekingen',
+ 'reservations.empty': 'Nog geen reserveringen',
+ 'reservations.emptyHint': 'Voeg reserveringen toe voor vluchten, hotels en meer',
+ 'reservations.add': 'Reservering toevoegen',
+ 'reservations.addManual': 'Handmatige boeking',
+ 'reservations.placeHint': 'Tip: Reserveringen kun je het beste direct vanuit een plaats aanmaken om ze te koppelen aan je dagplan.',
+ 'reservations.confirmed': 'Bevestigd',
+ 'reservations.pending': 'In behandeling',
+ 'reservations.summary': '{confirmed} bevestigd, {pending} in behandeling',
+ 'reservations.fromPlan': 'Vanuit plan',
+ 'reservations.showFiles': 'Bestanden tonen',
+ 'reservations.editTitle': 'Reservering bewerken',
+ 'reservations.status': 'Status',
+ 'reservations.datetime': 'Datum en tijd',
+ 'reservations.startTime': 'Starttijd',
+ 'reservations.endTime': 'Eindtijd',
+ 'reservations.date': 'Datum',
+ 'reservations.time': 'Tijd',
+ 'reservations.timeAlt': 'Tijd (alternatief, bijv. 19:30)',
+ 'reservations.notes': 'Notities',
+ 'reservations.notesPlaceholder': 'Extra notities...',
+ 'reservations.meta.airline': 'Luchtvaartmaatschappij',
+ 'reservations.meta.flightNumber': 'Vluchtnr.',
+ 'reservations.meta.from': 'Van',
+ 'reservations.meta.to': 'Naar',
+ 'reservations.meta.trainNumber': 'Treinnr.',
+ 'reservations.meta.platform': 'Perron',
+ 'reservations.meta.seat': 'Stoel',
+ 'reservations.meta.checkIn': 'Check-in',
+ 'reservations.meta.checkOut': 'Check-out',
+ 'reservations.meta.linkAccommodation': 'Accommodatie',
+ 'reservations.meta.pickAccommodation': 'Koppel aan accommodatie',
+ 'reservations.meta.noAccommodation': 'Geen',
+ 'reservations.meta.hotelPlace': 'Hotel',
+ 'reservations.meta.pickHotel': 'Selecteer hotel',
+ 'reservations.meta.fromDay': 'Van',
+ 'reservations.meta.toDay': 'Tot',
+ 'reservations.meta.selectDay': 'Selecteer dag',
+ 'reservations.type.flight': 'Vlucht',
+ 'reservations.type.hotel': 'Hotel',
+ 'reservations.type.restaurant': 'Restaurant',
+ 'reservations.type.train': 'Trein',
+ 'reservations.type.car': 'Huurauto',
+ 'reservations.type.cruise': 'Cruise',
+ 'reservations.type.event': 'Evenement',
+ 'reservations.type.tour': 'Rondleiding',
+ 'reservations.type.other': 'Overig',
+ 'reservations.confirm.delete': 'Weet je zeker dat je de reservering "{name}" wilt verwijderen?',
+ 'reservations.toast.updated': 'Reservering bijgewerkt',
+ 'reservations.toast.removed': 'Reservering verwijderd',
+ 'reservations.toast.fileUploaded': 'Bestand geüpload',
+ 'reservations.toast.uploadError': 'Uploaden mislukt',
+ 'reservations.newTitle': 'Nieuwe reservering',
+ 'reservations.bookingType': 'Boekingstype',
+ 'reservations.titleLabel': 'Titel',
+ 'reservations.titlePlaceholder': 'bijv. Lufthansa LH123, Hotel Adlon, ...',
+ 'reservations.locationAddress': 'Locatie / Adres',
+ 'reservations.locationPlaceholder': 'Adres, luchthaven, hotel...',
+ 'reservations.confirmationCode': 'Boekingscode',
+ 'reservations.confirmationPlaceholder': 'bijv. ABC12345',
+ 'reservations.day': 'Dag',
+ 'reservations.noDay': 'Geen dag',
+ 'reservations.place': 'Plaats',
+ 'reservations.noPlace': 'Geen plaats',
+ 'reservations.pendingSave': 'wordt opgeslagen…',
+ 'reservations.uploading': 'Uploaden...',
+ 'reservations.attachFile': 'Bestand bijvoegen',
+ 'reservations.toast.saveError': 'Opslaan mislukt',
+ 'reservations.toast.updateError': 'Bijwerken mislukt',
+ 'reservations.toast.deleteError': 'Verwijderen mislukt',
+ 'reservations.confirm.remove': 'Reservering voor "{name}" verwijderen?',
+ 'reservations.linkAssignment': 'Koppelen aan dagtoewijzing',
+ 'reservations.pickAssignment': 'Selecteer een toewijzing uit je plan...',
+ 'reservations.noAssignment': 'Geen koppeling (zelfstandig)',
+
+ // Budget
+ 'budget.title': 'Budget',
+ 'budget.emptyTitle': 'Nog geen budget aangemaakt',
+ 'budget.emptyText': 'Maak categorieën en invoeren aan om je reisbudget te plannen',
+ 'budget.emptyPlaceholder': 'Categorienaam invoeren...',
+ 'budget.createCategory': 'Categorie aanmaken',
+ 'budget.category': 'Categorie',
+ 'budget.categoryName': 'Categorienaam',
+ 'budget.table.name': 'Naam',
+ 'budget.table.total': 'Totaal',
+ 'budget.table.persons': 'Personen',
+ 'budget.table.days': 'Dagen',
+ 'budget.table.perPerson': 'Per persoon',
+ 'budget.table.perDay': 'Per dag',
+ 'budget.table.perPersonDay': 'P. p. / dag',
+ 'budget.table.note': 'Notitie',
+ 'budget.newEntry': 'Nieuwe invoer',
+ 'budget.defaultEntry': 'Nieuwe invoer',
+ 'budget.defaultCategory': 'Nieuwe categorie',
+ 'budget.total': 'Totaal',
+ 'budget.totalBudget': 'Totaal budget',
+ 'budget.byCategory': 'Per categorie',
+ 'budget.editTooltip': 'Klik om te bewerken',
+ 'budget.confirm.deleteCategory': 'Weet je zeker dat je de categorie "{name}" met {count} invoeren wilt verwijderen?',
+ 'budget.deleteCategory': 'Categorie verwijderen',
+ 'budget.perPerson': 'Per persoon',
+ 'budget.paid': 'Betaald',
+ 'budget.open': 'Open',
+ 'budget.noMembers': 'Geen leden toegewezen',
+
+ // Files
+ 'files.title': 'Bestanden',
+ 'files.count': '{count} bestanden',
+ 'files.countSingular': '1 bestand',
+ 'files.uploaded': '{count} geüpload',
+ 'files.uploadError': 'Uploaden mislukt',
+ 'files.dropzone': 'Sleep bestanden hierheen',
+ 'files.dropzoneHint': 'of klik om te bladeren',
+ 'files.allowedTypes': 'Afbeeldingen, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB',
+ 'files.uploading': 'Uploaden...',
+ 'files.filterAll': 'Alle',
+ 'files.filterPdf': 'PDF\'s',
+ 'files.filterImages': 'Afbeeldingen',
+ 'files.filterDocs': 'Documenten',
+ 'files.filterCollab': 'Collab-notities',
+ 'files.sourceCollab': 'Uit Collab-notities',
+ 'files.empty': 'Nog geen bestanden',
+ 'files.emptyHint': 'Upload bestanden om ze aan je reis toe te voegen',
+ 'files.openTab': 'Openen in nieuw tabblad',
+ 'files.confirm.delete': 'Weet je zeker dat je dit bestand wilt verwijderen?',
+ 'files.toast.deleted': 'Bestand verwijderd',
+ 'files.toast.deleteError': 'Bestand verwijderen mislukt',
+ 'files.sourcePlan': 'Dagplan',
+ 'files.sourceBooking': 'Boeking',
+ 'files.attach': 'Bijvoegen',
+ 'files.pasteHint': 'Je kunt ook afbeeldingen plakken vanuit het klembord (Ctrl+V)',
+ 'files.trash': 'Prullenbak',
+ 'files.trashEmpty': 'Prullenbak is leeg',
+ 'files.emptyTrash': 'Prullenbak legen',
+ 'files.restore': 'Herstellen',
+ 'files.star': 'Ster',
+ 'files.unstar': 'Ster verwijderen',
+ 'files.assign': 'Toewijzen',
+ 'files.assignTitle': 'Bestand toewijzen',
+ 'files.assignPlace': 'Plaats',
+ 'files.assignBooking': 'Boeking',
+ 'files.unassigned': 'Niet toegewezen',
+ 'files.unlink': 'Koppeling verwijderen',
+ 'files.toast.trashed': 'Naar prullenbak verplaatst',
+ 'files.toast.restored': 'Bestand hersteld',
+ 'files.toast.trashEmptied': 'Prullenbak geleegd',
+ 'files.toast.assigned': 'Bestand toegewezen',
+ 'files.toast.assignError': 'Toewijzing mislukt',
+ 'files.toast.restoreError': 'Herstellen mislukt',
+ 'files.confirm.permanentDelete': 'Dit bestand permanent verwijderen? Dit kan niet ongedaan worden gemaakt.',
+ 'files.confirm.emptyTrash': 'Alle bestanden in de prullenbak permanent verwijderen? Dit kan niet ongedaan worden gemaakt.',
+ 'files.noteLabel': 'Notitie',
+ 'files.notePlaceholder': 'Notitie toevoegen...',
+
+ // Packing
+ 'packing.title': 'Paklijst',
+ 'packing.empty': 'Paklijst is leeg',
+ 'packing.progress': '{packed} van {total} ingepakt ({percent}%)',
+ 'packing.clearChecked': '{count} aangevinkte verwijderen',
+ 'packing.clearCheckedShort': '{count} verwijderen',
+ 'packing.suggestions': 'Suggesties',
+ 'packing.suggestionsTitle': 'Suggesties toevoegen',
+ 'packing.allSuggested': 'Alle suggesties toegevoegd',
+ 'packing.allPacked': 'Alles ingepakt!',
+ 'packing.addPlaceholder': 'Nieuw item toevoegen...',
+ 'packing.categoryPlaceholder': 'Categorie...',
+ 'packing.filterAll': 'Alle',
+ 'packing.filterOpen': 'Open',
+ 'packing.filterDone': 'Klaar',
+ 'packing.emptyTitle': 'Paklijst is leeg',
+ 'packing.emptyHint': 'Voeg items toe of gebruik de suggesties',
+ 'packing.emptyFiltered': 'Geen items gevonden voor dit filter',
+ 'packing.menuRename': 'Hernoemen',
+ 'packing.menuCheckAll': 'Alles aanvinken',
+ 'packing.menuUncheckAll': 'Alles uitvinken',
+ 'packing.menuDeleteCat': 'Categorie verwijderen',
+ 'packing.changeCategory': 'Categorie wijzigen',
+ 'packing.confirm.clearChecked': 'Weet je zeker dat je {count} aangevinkte items wilt verwijderen?',
+ 'packing.confirm.deleteCat': 'Weet je zeker dat je de categorie "{name}" met {count} items wilt verwijderen?',
+ 'packing.defaultCategory': 'Overig',
+ 'packing.toast.saveError': 'Opslaan mislukt',
+ 'packing.toast.deleteError': 'Verwijderen mislukt',
+ 'packing.toast.renameError': 'Hernoemen mislukt',
+ 'packing.toast.addError': 'Toevoegen mislukt',
+
+ // Packing suggestions
+ 'packing.suggestions.items': [
+ { name: 'Paspoort', category: 'Documenten' },
+ { name: 'Identiteitskaart', category: 'Documenten' },
+ { name: 'Reisverzekering', category: 'Documenten' },
+ { name: 'Vliegtickets', category: 'Documenten' },
+ { name: 'Creditcard', category: 'Financiën' },
+ { name: 'Contant geld', category: 'Financiën' },
+ { name: 'Visum', category: 'Documenten' },
+ { name: 'T-shirts', category: 'Kleding' },
+ { name: 'Broeken', category: 'Kleding' },
+ { name: 'Ondergoed', category: 'Kleding' },
+ { name: 'Sokken', category: 'Kleding' },
+ { name: 'Jas', category: 'Kleding' },
+ { name: 'Slaapkleding', category: 'Kleding' },
+ { name: 'Zwemkleding', category: 'Kleding' },
+ { name: 'Regenjas', category: 'Kleding' },
+ { name: 'Comfortabele schoenen', category: 'Kleding' },
+ { name: 'Tandenborstel', category: 'Toiletartikelen' },
+ { name: 'Tandpasta', category: 'Toiletartikelen' },
+ { name: 'Shampoo', category: 'Toiletartikelen' },
+ { name: 'Deodorant', category: 'Toiletartikelen' },
+ { name: 'Zonnebrandcrème', category: 'Toiletartikelen' },
+ { name: 'Scheermesje', category: 'Toiletartikelen' },
+ { name: 'Oplader', category: 'Elektronica' },
+ { name: 'Powerbank', category: 'Elektronica' },
+ { name: 'Koptelefoon', category: 'Elektronica' },
+ { name: 'Reisadapter', category: 'Elektronica' },
+ { name: 'Camera', category: 'Elektronica' },
+ { name: 'Pijnstillers', category: 'Gezondheid' },
+ { name: 'Pleisters', category: 'Gezondheid' },
+ { name: 'Ontsmettingsmiddel', category: 'Gezondheid' },
+ ],
+
+ // Members / Sharing
+ 'members.shareTrip': 'Reis delen',
+ 'members.inviteUser': 'Gebruiker uitnodigen',
+ 'members.selectUser': 'Selecteer gebruiker…',
+ 'members.invite': 'Uitnodigen',
+ 'members.allHaveAccess': 'Alle gebruikers hebben al toegang.',
+ 'members.access': 'Toegang',
+ 'members.person': 'persoon',
+ 'members.persons': 'personen',
+ 'members.you': 'jij',
+ 'members.owner': 'Eigenaar',
+ 'members.leaveTrip': 'Reis verlaten',
+ 'members.removeAccess': 'Toegang verwijderen',
+ 'members.confirmLeave': 'Reis verlaten? Je verliest de toegang.',
+ 'members.confirmRemove': 'Toegang voor deze gebruiker verwijderen?',
+ 'members.loadError': 'Leden laden mislukt',
+ 'members.added': 'toegevoegd',
+ 'members.addError': 'Toevoegen mislukt',
+ 'members.removed': 'Lid verwijderd',
+ 'members.removeError': 'Verwijderen mislukt',
+
+ // Categories (Admin)
+ 'categories.title': 'Categorieën',
+ 'categories.subtitle': 'Beheer categorieën voor plaatsen',
+ 'categories.new': 'Nieuwe categorie',
+ 'categories.empty': 'Nog geen categorieën',
+ 'categories.namePlaceholder': 'Categorienaam',
+ 'categories.icon': 'Pictogram',
+ 'categories.color': 'Kleur',
+ 'categories.customColor': 'Kies een aangepaste kleur',
+ 'categories.preview': 'Voorbeeld',
+ 'categories.defaultName': 'Categorie',
+ 'categories.update': 'Bijwerken',
+ 'categories.create': 'Aanmaken',
+ 'categories.confirm.delete': 'Categorie verwijderen? Plaatsen in deze categorie worden niet verwijderd.',
+ 'categories.toast.loadError': 'Categorieën laden mislukt',
+ 'categories.toast.nameRequired': 'Voer een naam in',
+ 'categories.toast.updated': 'Categorie bijgewerkt',
+ 'categories.toast.created': 'Categorie aangemaakt',
+ 'categories.toast.saveError': 'Opslaan mislukt',
+ 'categories.toast.deleted': 'Categorie verwijderd',
+ 'categories.toast.deleteError': 'Verwijderen mislukt',
+
+ // Backup (Admin)
+ 'backup.title': 'Gegevensback-up',
+ 'backup.subtitle': 'Database en alle geüploade bestanden',
+ 'backup.refresh': 'Vernieuwen',
+ 'backup.upload': 'Back-up uploaden',
+ 'backup.uploading': 'Uploaden…',
+ 'backup.create': 'Back-up aanmaken',
+ 'backup.creating': 'Aanmaken…',
+ 'backup.empty': 'Nog geen back-ups',
+ 'backup.createFirst': 'Eerste back-up aanmaken',
+ 'backup.download': 'Downloaden',
+ 'backup.restore': 'Herstellen',
+ 'backup.confirm.restore': 'Back-up "{name}" herstellen?\n\nAlle huidige gegevens worden vervangen door de back-up.',
+ 'backup.confirm.uploadRestore': 'Back-upbestand "{name}" uploaden en herstellen?\n\nAlle huidige gegevens worden overschreven.',
+ 'backup.confirm.delete': 'Back-up "{name}" verwijderen?',
+ 'backup.toast.loadError': 'Back-ups laden mislukt',
+ 'backup.toast.created': 'Back-up succesvol aangemaakt',
+ 'backup.toast.createError': 'Back-up aanmaken mislukt',
+ 'backup.toast.restored': 'Back-up hersteld. Pagina wordt herladen…',
+ 'backup.toast.restoreError': 'Herstellen mislukt',
+ 'backup.toast.uploadError': 'Uploaden mislukt',
+ 'backup.toast.deleted': 'Back-up verwijderd',
+ 'backup.toast.deleteError': 'Verwijderen mislukt',
+ 'backup.toast.downloadError': 'Downloaden mislukt',
+ 'backup.toast.settingsSaved': 'Auto-back-up-instellingen opgeslagen',
+ 'backup.toast.settingsError': 'Instellingen opslaan mislukt',
+ 'backup.auto.title': 'Auto-back-up',
+ 'backup.auto.subtitle': 'Automatische back-up volgens schema',
+ 'backup.auto.enable': 'Auto-back-up inschakelen',
+ 'backup.auto.enableHint': 'Back-ups worden automatisch aangemaakt volgens het gekozen schema',
+ 'backup.auto.interval': 'Interval',
+ 'backup.auto.keepLabel': 'Oude back-ups verwijderen na',
+ 'backup.interval.hourly': 'Elk uur',
+ 'backup.interval.daily': 'Dagelijks',
+ 'backup.interval.weekly': 'Wekelijks',
+ 'backup.interval.monthly': 'Maandelijks',
+ 'backup.keep.1day': '1 dag',
+ 'backup.keep.3days': '3 dagen',
+ 'backup.keep.7days': '7 dagen',
+ 'backup.keep.14days': '14 dagen',
+ 'backup.keep.30days': '30 dagen',
+ 'backup.keep.forever': 'Voor altijd bewaren',
+
+ // Photos
+ 'photos.allDays': 'Alle dagen',
+ 'photos.noPhotos': 'Nog geen foto\'s',
+ 'photos.uploadHint': 'Upload je reisfoto\'s',
+ 'photos.clickToSelect': 'of klik om te selecteren',
+ 'photos.linkPlace': 'Koppel plaats',
+ 'photos.noPlace': 'Geen plaats',
+ 'photos.uploadN': '{n} foto(\'s) uploaden',
+
+ // Backup restore modal
+ 'backup.restoreConfirmTitle': 'Back-up herstellen?',
+ 'backup.restoreWarning': 'Alle huidige gegevens (reizen, plaatsen, gebruikers, uploads) worden permanent vervangen door de back-up. Deze actie kan niet ongedaan worden gemaakt.',
+ 'backup.restoreTip': 'Tip: Maak een back-up van de huidige status voordat je herstelt.',
+ 'backup.restoreConfirm': 'Ja, herstellen',
+
+ // PDF
+ 'pdf.travelPlan': 'Reisplan',
+ 'pdf.planned': 'Gepland',
+ 'pdf.costLabel': 'Kosten EUR',
+ 'pdf.preview': 'PDF-voorbeeld',
+ 'pdf.saveAsPdf': 'Opslaan als PDF',
+
+ // Planner
+ 'planner.places': 'Plaatsen',
+ 'planner.bookings': 'Boekingen',
+ 'planner.packingList': 'Paklijst',
+ 'planner.documents': 'Documenten',
+ 'planner.dayPlan': 'Dagplan',
+ 'planner.reservations': 'Reserveringen',
+ 'planner.minTwoPlaces': 'Minimaal 2 plaatsen met coördinaten nodig',
+ 'planner.noGeoPlaces': 'Geen plaatsen met coördinaten beschikbaar',
+ 'planner.routeCalculated': 'Route berekend',
+ 'planner.routeCalcFailed': 'Route kon niet worden berekend',
+ 'planner.routeError': 'Fout bij routeberekening',
+ 'planner.routeOptimized': 'Route geoptimaliseerd',
+ 'planner.reservationUpdated': 'Reservering bijgewerkt',
+ 'planner.reservationAdded': 'Reservering toegevoegd',
+ 'planner.confirmDeleteReservation': 'Reservering verwijderen?',
+ 'planner.reservationDeleted': 'Reservering verwijderd',
+ 'planner.days': 'Dagen',
+ 'planner.allPlaces': 'Alle plaatsen',
+ 'planner.totalPlaces': '{n} plaatsen totaal',
+ 'planner.noDaysPlanned': 'Nog geen dagen gepland',
+ 'planner.editTrip': 'Reis bewerken \u2192',
+ 'planner.placeOne': '1 plaats',
+ 'planner.placeN': '{n} plaatsen',
+ 'planner.addNote': 'Notitie toevoegen',
+ 'planner.noEntries': 'Geen invoeren voor deze dag',
+ 'planner.addPlace': 'Plaats/activiteit toevoegen',
+ 'planner.addPlaceShort': '+ Plaats/activiteit toevoegen',
+ 'planner.resPending': 'Reservering in behandeling · ',
+ 'planner.resConfirmed': 'Reservering bevestigd · ',
+ 'planner.notePlaceholder': 'Notitie…',
+ 'planner.noteTimePlaceholder': 'Tijd (optioneel)',
+ 'planner.noteExamplePlaceholder': 'bijv. S3 om 14:30 vanaf centraal station, veerboot van pier 7, lunchpauze…',
+ 'planner.totalCost': 'Totale kosten',
+ 'planner.searchPlaces': 'Plaatsen zoeken…',
+ 'planner.allCategories': 'Alle categorieën',
+ 'planner.noPlacesFound': 'Geen plaatsen gevonden',
+ 'planner.addFirstPlace': 'Eerste plaats toevoegen',
+ 'planner.noReservations': 'Geen reserveringen',
+ 'planner.addFirstReservation': 'Eerste reservering toevoegen',
+ 'planner.new': 'Nieuw',
+ 'planner.addToDay': '+ Dag',
+ 'planner.calculating': 'Berekenen…',
+ 'planner.route': 'Route',
+ 'planner.optimize': 'Optimaliseren',
+ 'planner.openGoogleMaps': 'Openen in Google Maps',
+ 'planner.selectDayHint': 'Selecteer een dag uit de lijst links om het dagplan te bekijken',
+ 'planner.noPlacesForDay': 'Nog geen plaatsen voor deze dag',
+ 'planner.addPlacesLink': 'Plaatsen toevoegen \u2192',
+ 'planner.minTotal': 'min. totaal',
+ 'planner.noReservation': 'Geen reservering',
+ 'planner.removeFromDay': 'Verwijderen van dag',
+ 'planner.addToThisDay': 'Toevoegen aan dag',
+ 'planner.overview': 'Overzicht',
+ 'planner.noDays': 'Geen dagen',
+ 'planner.editTripToAddDays': 'Bewerk de reis om dagen toe te voegen',
+ 'planner.dayCount': '{n} dagen',
+ 'planner.clickToUnlock': 'Klik om te ontgrendelen',
+ 'planner.keepPosition': 'Positie behouden tijdens route-optimalisatie',
+ 'planner.dayDetails': 'Dagdetails',
+ 'planner.dayN': 'Dag {n}',
+
+ // Dashboard Stats
+ 'stats.countries': 'Landen',
+ 'stats.cities': 'Steden',
+ 'stats.trips': 'Reizen',
+ 'stats.places': 'Plaatsen',
+ 'stats.worldProgress': 'Wereldvoortgang',
+ 'stats.visited': 'bezocht',
+ 'stats.remaining': 'resterend',
+ 'stats.visitedCountries': 'Bezochte landen',
+
+ // Day Detail Panel
+ 'day.precipProb': 'Regenkans',
+ 'day.precipitation': 'Neerslag',
+ 'day.wind': 'Wind',
+ 'day.sunrise': 'Zonsopgang',
+ 'day.sunset': 'Zonsondergang',
+ 'day.hourlyForecast': 'Uurlijkse voorspelling',
+ 'day.climateHint': 'Historische gemiddelden — echte voorspelling beschikbaar binnen 16 dagen voor deze datum.',
+ 'day.noWeather': 'Geen weergegevens beschikbaar. Voeg een plaats met coördinaten toe.',
+ 'day.overview': 'Dagoverzicht',
+ 'day.accommodation': 'Accommodatie',
+ 'day.addAccommodation': 'Accommodatie toevoegen',
+ 'day.hotelDayRange': 'Toepassen op dagen',
+ 'day.noPlacesForHotel': 'Voeg eerst plaatsen toe aan je reis',
+ 'day.allDays': 'Alle',
+ 'day.checkIn': 'Check-in',
+ 'day.checkOut': 'Check-out',
+ 'day.confirmation': 'Bevestiging',
+ 'day.editAccommodation': 'Accommodatie bewerken',
+ 'day.reservations': 'Reserveringen',
+
+ // Collab Addon
+ 'collab.tabs.chat': 'Chat',
+ 'collab.tabs.notes': 'Notities',
+ 'collab.tabs.polls': 'Polls',
+ 'collab.whatsNext.title': 'Wat komt er',
+ 'collab.whatsNext.today': 'Vandaag',
+ 'collab.whatsNext.tomorrow': 'Morgen',
+ 'collab.whatsNext.empty': 'Geen komende activiteiten',
+ 'collab.whatsNext.until': 'tot',
+ 'collab.whatsNext.emptyHint': 'Activiteiten met tijden verschijnen hier',
+ 'collab.chat.send': 'Verzenden',
+ 'collab.chat.placeholder': 'Typ een bericht...',
+ 'collab.chat.empty': 'Start het gesprek',
+ 'collab.chat.emptyHint': 'Berichten worden gedeeld met alle reisleden',
+ 'collab.chat.emptyDesc': 'Deel ideeën, plannen en updates met je reisgroep',
+ 'collab.chat.today': 'Vandaag',
+ 'collab.chat.yesterday': 'Gisteren',
+ 'collab.chat.deletedMessage': 'heeft een bericht verwijderd',
+ 'collab.chat.loadMore': 'Oudere berichten laden',
+ 'collab.chat.justNow': 'zojuist',
+ 'collab.chat.minutesAgo': '{n} min. geleden',
+ 'collab.chat.hoursAgo': '{n} uur geleden',
+ 'collab.notes.title': 'Notities',
+ 'collab.notes.new': 'Nieuwe notitie',
+ 'collab.notes.empty': 'Nog geen notities',
+ 'collab.notes.emptyHint': 'Begin met het vastleggen van ideeën en plannen',
+ 'collab.notes.all': 'Alle',
+ 'collab.notes.titlePlaceholder': 'Notitietitel',
+ 'collab.notes.contentPlaceholder': 'Schrijf iets...',
+ 'collab.notes.categoryPlaceholder': 'Categorie',
+ 'collab.notes.newCategory': 'Nieuwe categorie...',
+ 'collab.notes.category': 'Categorie',
+ 'collab.notes.noCategory': 'Geen categorie',
+ 'collab.notes.color': 'Kleur',
+ 'collab.notes.save': 'Opslaan',
+ 'collab.notes.cancel': 'Annuleren',
+ 'collab.notes.edit': 'Bewerken',
+ 'collab.notes.delete': 'Verwijderen',
+ 'collab.notes.pin': 'Vastpinnen',
+ 'collab.notes.unpin': 'Losmaken',
+ 'collab.notes.daysAgo': '{n}d geleden',
+ 'collab.notes.categorySettings': 'Categorieën beheren',
+ 'collab.notes.create': 'Aanmaken',
+ 'collab.notes.website': 'Website',
+ 'collab.notes.websitePlaceholder': 'https://...',
+ 'collab.notes.attachFiles': 'Bestanden bijvoegen',
+ 'collab.notes.noCategoriesYet': 'Nog geen categorieën',
+ 'collab.notes.emptyDesc': 'Maak een notitie om te beginnen',
+ 'collab.polls.title': 'Polls',
+ 'collab.polls.new': 'Nieuwe poll',
+ 'collab.polls.empty': 'Nog geen polls',
+ 'collab.polls.emptyHint': 'Stel de groep een vraag en stem samen',
+ 'collab.polls.question': 'Vraag',
+ 'collab.polls.questionPlaceholder': 'Wat zullen we doen?',
+ 'collab.polls.addOption': '+ Optie toevoegen',
+ 'collab.polls.optionPlaceholder': 'Optie {n}',
+ 'collab.polls.create': 'Poll aanmaken',
+ 'collab.polls.close': 'Sluiten',
+ 'collab.polls.closed': 'Gesloten',
+ 'collab.polls.votes': '{n} stemmen',
+ 'collab.polls.vote': '{n} stem',
+ 'collab.polls.multipleChoice': 'Meerkeuze',
+ 'collab.polls.multiChoice': 'Meerkeuze',
+ 'collab.polls.deadline': 'Deadline',
+ 'collab.polls.option': 'Optie',
+ 'collab.polls.options': 'Opties',
+ 'collab.polls.delete': 'Verwijderen',
+ 'collab.polls.closedSection': 'Gesloten',
+}
+
+export default nl
diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts
new file mode 100644
index 0000000..1610762
--- /dev/null
+++ b/client/src/i18n/translations/ru.ts
@@ -0,0 +1,1082 @@
+const ru: Record = {
+ // Common
+ 'common.save': 'Сохранить',
+ 'common.cancel': 'Отмена',
+ 'common.delete': 'Удалить',
+ 'common.edit': 'Редактировать',
+ 'common.add': 'Добавить',
+ 'common.loading': 'Загрузка...',
+ 'common.error': 'Ошибка',
+ 'common.back': 'Назад',
+ 'common.all': 'Все',
+ 'common.close': 'Закрыть',
+ 'common.open': 'Открыть',
+ 'common.upload': 'Загрузить',
+ 'common.search': 'Поиск',
+ 'common.confirm': 'Подтвердить',
+ 'common.ok': 'ОК',
+ 'common.yes': 'Да',
+ 'common.no': 'Нет',
+ 'common.or': 'или',
+ 'common.none': 'Нет',
+ 'common.date': 'Дата',
+ 'common.rename': 'Переименовать',
+ 'common.name': 'Имя',
+ 'common.email': 'Эл. почта',
+ 'common.password': 'Пароль',
+ 'common.saving': 'Сохранение...',
+ 'common.update': 'Обновить',
+ 'common.change': 'Изменить',
+ 'common.uploading': 'Загрузка…',
+ 'common.backToPlanning': 'Вернуться к планированию',
+ 'common.reset': 'Сбросить',
+
+ // Navbar
+ 'nav.trip': 'Поездка',
+ 'nav.share': 'Поделиться',
+ 'nav.settings': 'Настройки',
+ 'nav.admin': 'Админ',
+ 'nav.logout': 'Выйти',
+ 'nav.lightMode': 'Светлая тема',
+ 'nav.darkMode': 'Тёмная тема',
+ 'nav.autoMode': 'Авто',
+ 'nav.administrator': 'Администратор',
+
+ // Dashboard
+ 'dashboard.title': 'Мои поездки',
+ 'dashboard.subtitle.loading': 'Загрузка поездок...',
+ 'dashboard.subtitle.trips': '{count} поездок ({archived} в архиве)',
+ 'dashboard.subtitle.empty': 'Начните свою первую поездку',
+ 'dashboard.subtitle.activeOne': '{count} активная поездка',
+ 'dashboard.subtitle.activeMany': '{count} активных поездок',
+ 'dashboard.subtitle.archivedSuffix': ' · {count} в архиве',
+ 'dashboard.newTrip': 'Новая поездка',
+ 'dashboard.currency': 'Валюта',
+ 'dashboard.timezone': 'Часовые пояса',
+ 'dashboard.localTime': 'Местное',
+ 'dashboard.timezoneCustomTitle': 'Свой часовой пояс',
+ 'dashboard.timezoneCustomLabelPlaceholder': 'Название (необязательно)',
+ 'dashboard.timezoneCustomTzPlaceholder': 'напр. America/New_York',
+ 'dashboard.timezoneCustomAdd': 'Добавить',
+ 'dashboard.timezoneCustomErrorEmpty': 'Введите идентификатор часового пояса',
+ 'dashboard.timezoneCustomErrorInvalid': 'Неверный часовой пояс. Используйте формат Europe/Berlin',
+ 'dashboard.timezoneCustomErrorDuplicate': 'Уже добавлен',
+ 'dashboard.emptyTitle': 'Нет поездок',
+ 'dashboard.emptyText': 'Создайте свою первую поездку и начните планировать!',
+ 'dashboard.emptyButton': 'Создать первую поездку',
+ 'dashboard.nextTrip': 'Следующая поездка',
+ 'dashboard.shared': 'Общая',
+ 'dashboard.sharedBy': 'Поделился {name}',
+ 'dashboard.days': 'Дни',
+ 'dashboard.places': 'Места',
+ 'dashboard.archive': 'Архивировать',
+ 'dashboard.restore': 'Восстановить',
+ 'dashboard.archived': 'В архиве',
+ 'dashboard.status.ongoing': 'В процессе',
+ 'dashboard.status.today': 'Сегодня',
+ 'dashboard.status.tomorrow': 'Завтра',
+ 'dashboard.status.past': 'Прошло',
+ 'dashboard.status.daysLeft': 'осталось {count} дн.',
+ 'dashboard.toast.loadError': 'Не удалось загрузить поездки',
+ 'dashboard.toast.created': 'Поездка создана!',
+ 'dashboard.toast.createError': 'Не удалось создать поездку',
+ 'dashboard.toast.updated': 'Поездка обновлена!',
+ 'dashboard.toast.updateError': 'Не удалось обновить поездку',
+ 'dashboard.toast.deleted': 'Поездка удалена',
+ 'dashboard.toast.deleteError': 'Не удалось удалить поездку',
+ 'dashboard.toast.archived': 'Поездка архивирована',
+ 'dashboard.toast.archiveError': 'Не удалось архивировать поездку',
+ 'dashboard.toast.restored': 'Поездка восстановлена',
+ 'dashboard.toast.restoreError': 'Не удалось восстановить поездку',
+ 'dashboard.confirm.delete': 'Удалить поездку «{title}»? Все места и планы будут безвозвратно удалены.',
+ 'dashboard.editTrip': 'Редактировать поездку',
+ 'dashboard.createTrip': 'Создать новую поездку',
+ 'dashboard.tripTitle': 'Название',
+ 'dashboard.tripTitlePlaceholder': 'напр. Лето в Японии',
+ 'dashboard.tripDescription': 'Описание',
+ 'dashboard.tripDescriptionPlaceholder': 'О чём эта поездка?',
+ 'dashboard.startDate': 'Дата начала',
+ 'dashboard.endDate': 'Дата окончания',
+ 'dashboard.noDateHint': 'Дата не указана — будет создано 7 дней по умолчанию. Вы можете изменить это в любое время.',
+ 'dashboard.coverImage': 'Обложка',
+ 'dashboard.addCoverImage': 'Добавить обложку',
+ 'dashboard.coverSaved': 'Обложка сохранена',
+ 'dashboard.coverUploadError': 'Ошибка загрузки',
+ 'dashboard.coverRemoveError': 'Ошибка удаления',
+ 'dashboard.titleRequired': 'Название обязательно',
+ 'dashboard.endDateError': 'Дата окончания должна быть позже даты начала',
+
+ // Settings
+ 'settings.title': 'Настройки',
+ 'settings.subtitle': 'Настройте свои персональные параметры',
+ 'settings.map': 'Карта',
+ 'settings.mapTemplate': 'Шаблон карты',
+ 'settings.mapTemplatePlaceholder.select': 'Выберите шаблон...',
+ 'settings.mapDefaultHint': 'Оставьте пустым для OpenStreetMap (по умолчанию)',
+ 'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
+ 'settings.mapHint': 'URL-шаблон для тайлов карты',
+ 'settings.latitude': 'Широта',
+ 'settings.longitude': 'Долгота',
+ 'settings.saveMap': 'Сохранить карту',
+ 'settings.apiKeys': 'API-ключи',
+ 'settings.mapsKey': 'API-ключ Google Maps',
+ 'settings.mapsKeyHint': 'Для поиска мест. Требуется Places API (New). Получите на console.cloud.google.com',
+ 'settings.weatherKey': 'API-ключ OpenWeatherMap',
+ 'settings.weatherKeyHint': 'Для данных о погоде. Бесплатно на openweathermap.org/api',
+ 'settings.keyPlaceholder': 'Введите ключ...',
+ 'settings.configured': 'Настроено',
+ 'settings.saveKeys': 'Сохранить ключи',
+ 'settings.display': 'Отображение',
+ 'settings.colorMode': 'Цветовая схема',
+ 'settings.light': 'Светлая',
+ 'settings.dark': 'Тёмная',
+ 'settings.auto': 'Авто',
+ 'settings.language': 'Язык',
+ 'settings.temperature': 'Единица температуры',
+ 'settings.timeFormat': 'Формат времени',
+ 'settings.routeCalculation': 'Расчёт маршрута',
+ 'settings.on': 'Вкл.',
+ 'settings.off': 'Выкл.',
+ 'settings.account': 'Аккаунт',
+ 'settings.username': 'Имя пользователя',
+ 'settings.email': 'Эл. почта',
+ 'settings.role': 'Роль',
+ 'settings.roleAdmin': 'Администратор',
+ 'settings.oidcLinked': 'Связан с',
+ 'settings.changePassword': 'Изменить пароль',
+ 'settings.currentPassword': 'Текущий пароль',
+ 'settings.currentPasswordRequired': 'Текущий пароль обязателен',
+ 'settings.newPassword': 'Новый пароль',
+ 'settings.confirmPassword': 'Подтвердите новый пароль',
+ 'settings.updatePassword': 'Обновить пароль',
+ 'settings.passwordRequired': 'Введите текущий и новый пароль',
+ 'settings.passwordTooShort': 'Пароль должен содержать не менее 8 символов',
+ 'settings.passwordMismatch': 'Пароли не совпадают',
+ 'settings.passwordWeak': 'Пароль должен содержать заглавные, строчные буквы и цифру',
+ 'settings.passwordChanged': 'Пароль успешно изменён',
+ 'settings.deleteAccount': 'Удалить аккаунт',
+ 'settings.deleteAccountTitle': 'Удалить ваш аккаунт?',
+ 'settings.deleteAccountWarning': 'Ваш аккаунт и все поездки, места и файлы будут безвозвратно удалены. Это действие нельзя отменить.',
+ 'settings.deleteAccountConfirm': 'Удалить безвозвратно',
+ 'settings.deleteBlockedTitle': 'Удаление невозможно',
+ 'settings.deleteBlockedMessage': 'Вы единственный администратор. Назначьте другого пользователя администратором перед удалением своего аккаунта.',
+ 'settings.roleUser': 'Пользователь',
+ 'settings.saveProfile': 'Сохранить профиль',
+ 'settings.toast.mapSaved': 'Настройки карты сохранены',
+ 'settings.toast.keysSaved': 'API-ключи сохранены',
+ 'settings.toast.displaySaved': 'Настройки отображения сохранены',
+ 'settings.toast.profileSaved': 'Профиль сохранён',
+ 'settings.uploadAvatar': 'Загрузить фото профиля',
+ 'settings.removeAvatar': 'Удалить фото профиля',
+ 'settings.avatarUploaded': 'Фото профиля обновлено',
+ 'settings.avatarRemoved': 'Фото профиля удалено',
+ 'settings.avatarError': 'Ошибка загрузки',
+
+ // Login
+ 'login.error': 'Ошибка входа. Проверьте свои учётные данные.',
+ 'login.tagline': 'Ваши поездки.\nВаш план.',
+ 'login.description': 'Планируйте поездки совместно с интерактивными картами, бюджетами и синхронизацией в реальном времени.',
+ 'login.features.maps': 'Интерактивные карты',
+ 'login.features.mapsDesc': 'Google Places, маршруты и кластеризация',
+ 'login.features.realtime': 'Синхронизация в реальном времени',
+ 'login.features.realtimeDesc': 'Планируйте вместе через WebSocket',
+ 'login.features.budget': 'Контроль бюджета',
+ 'login.features.budgetDesc': 'Категории, графики и расходы на человека',
+ 'login.features.collab': 'Совместная работа',
+ 'login.features.collabDesc': 'Многопользовательский режим с общими поездками',
+ 'login.features.packing': 'Списки вещей',
+ 'login.features.packingDesc': 'Категории, прогресс и подсказки',
+ 'login.features.bookings': 'Бронирования',
+ 'login.features.bookingsDesc': 'Авиабилеты, отели, рестораны и многое другое',
+ 'login.features.files': 'Документы',
+ 'login.features.filesDesc': 'Загружайте и управляйте документами',
+ 'login.features.routes': 'Умные маршруты',
+ 'login.features.routesDesc': 'Автооптимизация и экспорт в Google Maps',
+ 'login.selfHosted': 'Самостоятельный хостинг · Открытый код · Ваши данные остаются у вас',
+ 'login.title': 'Вход',
+ 'login.subtitle': 'С возвращением',
+ 'login.signingIn': 'Вход…',
+ 'login.signIn': 'Войти',
+ 'login.createAdmin': 'Создать аккаунт администратора',
+ 'login.createAdminHint': 'Настройте первый аккаунт администратора для TREK.',
+ 'login.createAccount': 'Создать аккаунт',
+ 'login.createAccountHint': 'Зарегистрируйте новый аккаунт.',
+ 'login.creating': 'Создание…',
+ 'login.noAccount': 'Нет аккаунта?',
+ 'login.hasAccount': 'Уже есть аккаунт?',
+ 'login.register': 'Регистрация',
+ 'login.emailPlaceholder': 'ваш@email.com',
+ 'login.username': 'Имя пользователя',
+ 'login.oidc.registrationDisabled': 'Регистрация отключена. Обратитесь к администратору.',
+ 'login.oidc.noEmail': 'Провайдер не предоставил адрес эл. почты.',
+ 'login.oidc.tokenFailed': 'Аутентификация не удалась.',
+ 'login.oidc.invalidState': 'Недействительная сессия. Попробуйте снова.',
+ 'login.demoFailed': 'Ошибка демо-входа',
+ 'login.oidcSignIn': 'Войти через {name}',
+ 'login.oidcOnly': 'Вход по паролю отключён. Используйте вашего провайдера SSO для входа.',
+ 'login.demoHint': 'Попробуйте демо — регистрация не требуется',
+
+ // Register
+ 'register.passwordMismatch': 'Пароли не совпадают',
+ 'register.passwordTooShort': 'Пароль должен содержать не менее 6 символов',
+ 'register.failed': 'Ошибка регистрации',
+ 'register.getStarted': 'Начать',
+ 'register.subtitle': 'Создайте аккаунт и начните планировать поездки мечты.',
+ 'register.feature1': 'Неограниченные планы поездок',
+ 'register.feature2': 'Интерактивная карта',
+ 'register.feature3': 'Управление местами и категориями',
+ 'register.feature4': 'Отслеживание бронирований',
+ 'register.feature5': 'Создание списков вещей',
+ 'register.feature6': 'Хранение фото и файлов',
+ 'register.createAccount': 'Создать аккаунт',
+ 'register.startPlanning': 'Начните планировать свои поездки',
+ 'register.minChars': 'Мин. 6 символов',
+ 'register.confirmPassword': 'Подтвердите пароль',
+ 'register.repeatPassword': 'Повторите пароль',
+ 'register.registering': 'Регистрация...',
+ 'register.register': 'Зарегистрироваться',
+ 'register.hasAccount': 'Уже есть аккаунт?',
+ 'register.signIn': 'Войти',
+
+ // Admin
+ 'admin.title': 'Администрирование',
+ 'admin.subtitle': 'Управление пользователями и системные настройки',
+ 'admin.tabs.users': 'Пользователи',
+ 'admin.tabs.categories': 'Категории',
+ 'admin.tabs.backup': 'Резервная копия',
+ 'admin.stats.users': 'Пользователи',
+ 'admin.stats.trips': 'Поездки',
+ 'admin.stats.places': 'Места',
+ 'admin.stats.photos': 'Фото',
+ 'admin.stats.files': 'Файлы',
+ 'admin.table.user': 'Пользователь',
+ 'admin.table.email': 'Эл. почта',
+ 'admin.table.role': 'Роль',
+ 'admin.table.created': 'Создан',
+ 'admin.table.lastLogin': 'Последний вход',
+ 'admin.table.actions': 'Действия',
+ 'admin.you': '(Вы)',
+ 'admin.editUser': 'Редактировать пользователя',
+ 'admin.newPassword': 'Новый пароль',
+ 'admin.newPasswordHint': 'Оставьте пустым, чтобы сохранить текущий пароль',
+ 'admin.deleteUser': 'Удалить пользователя «{name}»? Все поездки будут безвозвратно удалены.',
+ 'admin.deleteUserTitle': 'Удалить пользователя',
+ 'admin.newPasswordPlaceholder': 'Введите новый пароль…',
+ 'admin.toast.loadError': 'Не удалось загрузить данные администрирования',
+ 'admin.toast.userUpdated': 'Пользователь обновлён',
+ 'admin.toast.updateError': 'Ошибка обновления',
+ 'admin.toast.userDeleted': 'Пользователь удалён',
+ 'admin.toast.deleteError': 'Ошибка удаления',
+ 'admin.toast.cannotDeleteSelf': 'Нельзя удалить собственный аккаунт',
+ 'admin.toast.userCreated': 'Пользователь создан',
+ 'admin.toast.createError': 'Ошибка создания пользователя',
+ 'admin.toast.fieldsRequired': 'Имя пользователя, эл. почта и пароль обязательны',
+ 'admin.createUser': 'Создать пользователя',
+ 'admin.tabs.settings': 'Настройки',
+ 'admin.allowRegistration': 'Разрешить регистрацию',
+ 'admin.allowRegistrationHint': 'Новые пользователи могут регистрироваться самостоятельно',
+ 'admin.apiKeys': 'API-ключи',
+ 'admin.apiKeysHint': 'Необязательно. Включает расширенные данные о местах, такие как фото и погода.',
+ 'admin.mapsKey': 'API-ключ Google Maps',
+ 'admin.mapsKeyHint': 'Необходим для поиска мест. Получите на console.cloud.google.com',
+ 'admin.mapsKeyHintLong': 'Без API-ключа используется OpenStreetMap для поиска мест. С ключом Google API можно загружать фото, рейтинги и часы работы. Получите ключ на console.cloud.google.com.',
+ 'admin.recommended': 'Рекомендуется',
+ 'admin.weatherKey': 'API-ключ OpenWeatherMap',
+ 'admin.weatherKeyHint': 'Для данных о погоде. Бесплатно на openweathermap.org',
+ 'admin.validateKey': 'Проверить',
+ 'admin.keyValid': 'Подключено',
+ 'admin.keyInvalid': 'Недействителен',
+ 'admin.keySaved': 'API-ключи сохранены',
+ 'admin.oidcTitle': 'Единый вход (OIDC)',
+ 'admin.oidcSubtitle': 'Разрешить вход через внешних провайдеров, таких как Google, Apple, Authentik или Keycloak.',
+ 'admin.oidcDisplayName': 'Отображаемое имя',
+ 'admin.oidcIssuer': 'URL издателя',
+ 'admin.oidcIssuerHint': 'URL издателя OpenID Connect провайдера. Напр. https://accounts.google.com',
+ 'admin.oidcSaved': 'Конфигурация OIDC сохранена',
+ 'admin.oidcOnlyMode': 'Отключить вход по паролю',
+ 'admin.oidcOnlyModeHint': 'При включении разрешён только вход через SSO. Вход и регистрация по паролю будут заблокированы.',
+
+ // File Types
+ 'admin.fileTypes': 'Разрешённые типы файлов',
+ 'admin.fileTypesHint': 'Настройте, какие типы файлов могут загружать пользователи.',
+ 'admin.fileTypesFormat': 'Расширения через запятую (напр. jpg,png,pdf,doc). Используйте * для разрешения всех типов.',
+ 'admin.fileTypesSaved': 'Настройки типов файлов сохранены',
+
+ // Addons
+ 'admin.tabs.addons': 'Дополнения',
+ 'admin.addons.title': 'Дополнения',
+ 'admin.addons.subtitle': 'Включайте или отключайте функции для настройки TREK под себя.',
+ 'admin.addons.catalog.memories.name': 'Воспоминания',
+ 'admin.addons.catalog.memories.description': 'Общие фотоальбомы для каждой поездки',
+ 'admin.addons.catalog.packing.name': 'Сборы',
+ 'admin.addons.catalog.packing.description': 'Чек-листы для подготовки багажа к каждой поездке',
+ 'admin.addons.catalog.budget.name': 'Бюджет',
+ 'admin.addons.catalog.budget.description': 'Отслеживайте расходы и планируйте бюджет поездки',
+ 'admin.addons.catalog.documents.name': 'Документы',
+ 'admin.addons.catalog.documents.description': 'Храните и управляйте документами для путешествий',
+ 'admin.addons.catalog.vacay.name': 'Vacay',
+ 'admin.addons.catalog.vacay.description': 'Личный планировщик отпусков с календарём',
+ 'admin.addons.catalog.atlas.name': 'Atlas',
+ 'admin.addons.catalog.atlas.description': 'Карта мира с посещёнными странами и статистикой путешествий',
+ 'admin.addons.catalog.collab.name': 'Collab',
+ 'admin.addons.catalog.collab.description': 'Заметки в реальном времени, опросы и чат для планирования поездок',
+ 'admin.addons.subtitleBefore': 'Включайте или отключайте функции для настройки ',
+ 'admin.addons.subtitleAfter': ' под себя.',
+ 'admin.addons.enabled': 'Включено',
+ 'admin.addons.disabled': 'Отключено',
+ 'admin.addons.type.trip': 'Поездка',
+ 'admin.addons.type.global': 'Глобально',
+ 'admin.addons.tripHint': 'Доступно как вкладка внутри каждой поездки',
+ 'admin.addons.globalHint': 'Доступно как отдельный раздел в основной навигации',
+ 'admin.addons.toast.updated': 'Дополнение обновлено',
+ 'admin.addons.toast.error': 'Не удалось обновить дополнение',
+ 'admin.addons.noAddons': 'Нет доступных дополнений',
+ // Weather info
+ 'admin.weather.title': 'Данные о погоде',
+ 'admin.weather.badge': 'С 24 марта 2026',
+ 'admin.weather.description': 'TREK использует Open-Meteo как источник данных о погоде. Open-Meteo — бесплатный сервис с открытым кодом, API-ключ не требуется.',
+ 'admin.weather.forecast': 'Прогноз на 16 дней',
+ 'admin.weather.forecastDesc': 'Ранее 5 дней (OpenWeatherMap)',
+ 'admin.weather.climate': 'Исторические климатические данные',
+ 'admin.weather.climateDesc': 'Средние значения за последние 85 лет для дней за пределами 16-дневного прогноза',
+ 'admin.weather.requests': '10 000 запросов / день',
+ 'admin.weather.requestsDesc': 'Бесплатно, API-ключ не требуется',
+ 'admin.weather.locationHint': 'Погода основана на первом месте с координатами в каждом дне. Если ни одно место не назначено на день, в качестве ориентира используется любое место из списка.',
+
+ // GitHub
+ 'admin.tabs.github': 'GitHub',
+ 'admin.github.title': 'История релизов',
+ 'admin.github.subtitle': 'Последние обновления из {repo}',
+ 'admin.github.latest': 'Последний',
+ 'admin.github.prerelease': 'Пре-релиз',
+ 'admin.github.showDetails': 'Показать подробности',
+ 'admin.github.hideDetails': 'Скрыть подробности',
+ 'admin.github.loadMore': 'Загрузить ещё',
+ 'admin.github.loading': 'Загрузка...',
+ 'admin.github.error': 'Не удалось загрузить релизы',
+ 'admin.github.by': 'от',
+
+ 'admin.update.available': 'Доступно обновление',
+ 'admin.update.text': 'Доступна версия TREK {version}. У вас установлена {current}.',
+ 'admin.update.button': 'Посмотреть на GitHub',
+ 'admin.update.install': 'Установить обновление',
+ 'admin.update.confirmTitle': 'Установить обновление?',
+ 'admin.update.confirmText': 'TREK будет обновлён с {current} до {version}. Сервер перезапустится автоматически.',
+ 'admin.update.dataInfo': 'Все ваши данные (поездки, пользователи, API-ключи, загрузки, Vacay, Atlas, бюджеты) будут сохранены.',
+ 'admin.update.warning': 'Приложение будет кратковременно недоступно во время перезапуска.',
+ 'admin.update.confirm': 'Обновить сейчас',
+ 'admin.update.installing': 'Обновление…',
+ 'admin.update.success': 'Обновление установлено! Сервер перезапускается…',
+ 'admin.update.failed': 'Ошибка обновления',
+ 'admin.update.backupHint': 'Рекомендуем создать резервную копию перед обновлением.',
+ 'admin.update.backupLink': 'Перейти к резервным копиям',
+ 'admin.update.howTo': 'Как обновить',
+ 'admin.update.dockerText': 'Ваш экземпляр TREK работает в Docker. Для обновления до {version} выполните следующие команды на сервере:',
+ 'admin.update.reloadHint': 'Перезагрузите страницу через несколько секунд.',
+
+ // Vacay addon
+ 'vacay.subtitle': 'Планируйте и управляйте днями отпуска',
+ 'vacay.settings': 'Настройки',
+ 'vacay.year': 'Год',
+ 'vacay.addYear': 'Добавить год',
+ 'vacay.removeYear': 'Удалить год',
+ 'vacay.removeYearConfirm': 'Удалить {year}?',
+ 'vacay.removeYearHint': 'Все записи об отпуске и корпоративные выходные за этот год будут безвозвратно удалены.',
+ 'vacay.remove': 'Удалить',
+ 'vacay.persons': 'Люди',
+ 'vacay.noPersons': 'Никто не добавлен',
+ 'vacay.addPerson': 'Добавить человека',
+ 'vacay.editPerson': 'Редактировать',
+ 'vacay.removePerson': 'Удалить человека',
+ 'vacay.removePersonConfirm': 'Удалить {name}?',
+ 'vacay.removePersonHint': 'Все записи об отпуске этого человека будут безвозвратно удалены.',
+ 'vacay.personName': 'Имя',
+ 'vacay.personNamePlaceholder': 'Введите имя',
+ 'vacay.color': 'Цвет',
+ 'vacay.add': 'Добавить',
+ 'vacay.legend': 'Легенда',
+ 'vacay.publicHoliday': 'Государственный праздник',
+ 'vacay.companyHoliday': 'Корпоративный выходной',
+ 'vacay.weekend': 'Выходные',
+ 'vacay.modeVacation': 'Отпуск',
+ 'vacay.modeCompany': 'Корпоративный выходной',
+ 'vacay.entitlement': 'Право на отпуск',
+ 'vacay.entitlementDays': 'Дни',
+ 'vacay.used': 'Использовано',
+ 'vacay.remaining': 'Осталось',
+ 'vacay.carriedOver': 'из {year}',
+ 'vacay.blockWeekends': 'Блокировать выходные',
+ 'vacay.blockWeekendsHint': 'Запретить записи об отпуске в субботу и воскресенье',
+ 'vacay.publicHolidays': 'Государственные праздники',
+ 'vacay.publicHolidaysHint': 'Отмечать государственные праздники в календаре',
+ 'vacay.selectCountry': 'Выберите страну',
+ 'vacay.selectRegion': 'Выберите регион (необязательно)',
+ 'vacay.companyHolidays': 'Корпоративные выходные',
+ 'vacay.companyHolidaysHint': 'Разрешить отмечать корпоративные выходные дни',
+ 'vacay.companyHolidaysNoDeduct': 'Корпоративные выходные не вычитаются из дней отпуска.',
+ 'vacay.carryOver': 'Перенос',
+ 'vacay.carryOverHint': 'Автоматически переносить оставшиеся дни отпуска на следующий год',
+ 'vacay.sharing': 'Общий доступ',
+ 'vacay.sharingHint': 'Поделитесь планом отпуска с другими пользователями TREK',
+ 'vacay.owner': 'Владелец',
+ 'vacay.shareEmailPlaceholder': 'Эл. почта пользователя TREK',
+ 'vacay.shareSuccess': 'План успешно предоставлен',
+ 'vacay.shareError': 'Не удалось поделиться планом',
+ 'vacay.dissolve': 'Разделить объединение',
+ 'vacay.dissolveHint': 'Снова разделить календари. Ваши записи будут сохранены.',
+ 'vacay.dissolveAction': 'Разделить',
+ 'vacay.dissolved': 'Календарь разделён',
+ 'vacay.fusedWith': 'Объединён с',
+ 'vacay.you': 'вы',
+ 'vacay.noData': 'Нет данных',
+ 'vacay.changeColor': 'Изменить цвет',
+ 'vacay.inviteUser': 'Пригласить пользователя',
+ 'vacay.inviteHint': 'Пригласите другого пользователя TREK для совместного календаря отпусков.',
+ 'vacay.selectUser': 'Выберите пользователя',
+ 'vacay.sendInvite': 'Отправить приглашение',
+ 'vacay.inviteSent': 'Приглашение отправлено',
+ 'vacay.inviteError': 'Не удалось отправить приглашение',
+ 'vacay.pending': 'ожидание',
+ 'vacay.noUsersAvailable': 'Нет доступных пользователей',
+ 'vacay.accept': 'Принять',
+ 'vacay.decline': 'Отклонить',
+ 'vacay.acceptFusion': 'Принять и объединить',
+ 'vacay.inviteTitle': 'Запрос на объединение',
+ 'vacay.inviteWantsToFuse': 'хочет объединить календарь отпусков с вами.',
+ 'vacay.fuseInfo1': 'Вы оба будете видеть все записи об отпуске в одном общем календаре.',
+ 'vacay.fuseInfo2': 'Обе стороны могут создавать и редактировать записи друг для друга.',
+ 'vacay.fuseInfo3': 'Обе стороны могут удалять записи и изменять право на отпуск.',
+ 'vacay.fuseInfo4': 'Настройки, такие как праздники и корпоративные выходные, становятся общими.',
+ 'vacay.fuseInfo5': 'Объединение можно отменить в любое время любой из сторон. Ваши записи будут сохранены.',
+ 'nav.myTrips': 'Мои поездки',
+
+ // Atlas addon
+ 'atlas.subtitle': 'Ваш след путешествий по всему миру',
+ 'atlas.countries': 'Страны',
+ 'atlas.trips': 'Поездки',
+ 'atlas.places': 'Места',
+ 'atlas.days': 'Дни',
+ 'atlas.visitedCountries': 'Посещённые страны',
+ 'atlas.cities': 'Города',
+ 'atlas.noData': 'Данных о поездках пока нет',
+ 'atlas.noDataHint': 'Создайте поездку и добавьте места, чтобы увидеть карту мира',
+ 'atlas.lastTrip': 'Последняя поездка',
+ 'atlas.nextTrip': 'Следующая поездка',
+ 'atlas.daysLeft': 'дней осталось',
+ 'atlas.streak': 'Серия',
+ 'atlas.year': 'год',
+ 'atlas.years': 'лет',
+ 'atlas.yearInRow': 'год подряд',
+ 'atlas.yearsInRow': 'лет подряд',
+ 'atlas.tripIn': 'поездка в',
+ 'atlas.tripsIn': 'поездок в',
+ 'atlas.since': 'с',
+ 'atlas.europe': 'Европа',
+ 'atlas.asia': 'Азия',
+ 'atlas.northAmerica': 'Сев. Америка',
+ 'atlas.southAmerica': 'Юж. Америка',
+ 'atlas.africa': 'Африка',
+ 'atlas.oceania': 'Океания',
+ 'atlas.other': 'Другое',
+ 'atlas.firstVisit': 'Первая поездка',
+ 'atlas.lastVisitLabel': 'Последняя поездка',
+ 'atlas.tripSingular': 'Поездка',
+ 'atlas.tripPlural': 'Поездки',
+ 'atlas.placeVisited': 'Посещённое место',
+ 'atlas.placesVisited': 'Посещённые места',
+
+ // Trip Planner
+ 'trip.tabs.plan': 'План',
+ 'trip.tabs.reservations': 'Бронирования',
+ 'trip.tabs.reservationsShort': 'Брони',
+ 'trip.tabs.packing': 'Список вещей',
+ 'trip.tabs.packingShort': 'Вещи',
+ 'trip.tabs.budget': 'Бюджет',
+ 'trip.tabs.files': 'Файлы',
+ 'trip.loading': 'Загрузка поездки...',
+ 'trip.mobilePlan': 'План',
+ 'trip.mobilePlaces': 'Места',
+ 'trip.toast.placeUpdated': 'Место обновлено',
+ 'trip.toast.placeAdded': 'Место добавлено',
+ 'trip.toast.placeDeleted': 'Место удалено',
+ 'trip.toast.selectDay': 'Сначала выберите день',
+ 'trip.toast.assignedToDay': 'Место назначено на день',
+ 'trip.toast.reorderError': 'Ошибка изменения порядка',
+ 'trip.toast.reservationUpdated': 'Бронирование обновлено',
+ 'trip.toast.reservationAdded': 'Бронирование добавлено',
+ 'trip.toast.deleted': 'Удалено',
+ 'trip.confirm.deletePlace': 'Вы уверены, что хотите удалить это место?',
+
+ // Day Plan Sidebar
+ 'dayplan.emptyDay': 'На этот день мест не запланировано',
+ 'dayplan.addNote': 'Добавить заметку',
+ 'dayplan.editNote': 'Редактировать заметку',
+ 'dayplan.noteAdd': 'Добавить заметку',
+ 'dayplan.noteEdit': 'Редактировать заметку',
+ 'dayplan.noteTitle': 'Заметка',
+ 'dayplan.noteSubtitle': 'Заметка на день',
+ 'dayplan.totalCost': 'Общая стоимость',
+ 'dayplan.days': 'Дни',
+ 'dayplan.dayN': 'День {n}',
+ 'dayplan.calculating': 'Расчёт...',
+ 'dayplan.route': 'Маршрут',
+ 'dayplan.optimize': 'Оптимизировать',
+ 'dayplan.optimized': 'Маршрут оптимизирован',
+ 'dayplan.routeError': 'Не удалось рассчитать маршрут',
+ 'dayplan.toast.needTwoPlaces': 'Для оптимизации маршрута нужно минимум два места',
+ 'dayplan.toast.routeOptimized': 'Маршрут оптимизирован',
+ 'dayplan.toast.noGeoPlaces': 'Не найдено мест с координатами для расчёта маршрута',
+ 'dayplan.confirmed': 'Подтверждено',
+ 'dayplan.pendingRes': 'Ожидание',
+ 'dayplan.pdf': 'PDF',
+ 'dayplan.pdfTooltip': 'Экспортировать план дня в PDF',
+ 'dayplan.pdfError': 'Ошибка экспорта PDF',
+
+ // Places Sidebar
+ 'places.addPlace': 'Добавить место/активность',
+ 'places.assignToDay': 'Добавить в какой день?',
+ 'places.all': 'Все',
+ 'places.unplanned': 'Незапланированные',
+ 'places.search': 'Поиск мест...',
+ 'places.allCategories': 'Все категории',
+ 'places.count': '{count} мест',
+ 'places.countSingular': '1 место',
+ 'places.allPlanned': 'Все места запланированы',
+ 'places.noneFound': 'Места не найдены',
+ 'places.editPlace': 'Редактировать место',
+ 'places.formName': 'Название',
+ 'places.formNamePlaceholder': 'напр. Эйфелева башня',
+ 'places.formDescription': 'Описание',
+ 'places.formDescriptionPlaceholder': 'Краткое описание...',
+ 'places.formAddress': 'Адрес',
+ 'places.formAddressPlaceholder': 'Улица, город, страна',
+ 'places.formLat': 'Широта (напр. 48.8566)',
+ 'places.formLng': 'Долгота (напр. 2.3522)',
+ 'places.formCategory': 'Категория',
+ 'places.noCategory': 'Без категории',
+ 'places.categoryNamePlaceholder': 'Название категории',
+ 'places.formTime': 'Время',
+ 'places.startTime': 'Начало',
+ 'places.endTime': 'Конец',
+ 'places.endTimeBeforeStart': 'Время окончания раньше времени начала',
+ 'places.timeCollision': 'Пересечение по времени с:',
+ 'places.formWebsite': 'Сайт',
+ 'places.formNotesPlaceholder': 'Личные заметки...',
+ 'places.formReservation': 'Бронирование',
+ 'places.reservationNotesPlaceholder': 'Заметки о бронировании, номер подтверждения...',
+ 'places.mapsSearchPlaceholder': 'Поиск мест...',
+ 'places.mapsSearchError': 'Ошибка поиска мест.',
+ 'places.osmHint': 'Поиск через OpenStreetMap (без фото, часов работы и рейтингов). Добавьте API-ключ Google в настройках для полной информации.',
+ 'places.osmActive': 'Поиск через OpenStreetMap (без фото, рейтингов и часов работы). Добавьте API-ключ Google в настройках для расширенных данных.',
+ 'places.categoryCreateError': 'Не удалось создать категорию',
+ 'places.nameRequired': 'Введите название',
+ 'places.saveError': 'Ошибка сохранения',
+ // Place Inspector
+ 'inspector.opened': 'Открыто',
+ 'inspector.closed': 'Закрыто',
+ 'inspector.openingHours': 'Часы работы',
+ 'inspector.showHours': 'Показать часы работы',
+ 'inspector.files': 'Файлы',
+ 'inspector.filesCount': '{count} файлов',
+ 'inspector.removeFromDay': 'Убрать из дня',
+ 'inspector.addToDay': 'Добавить в день',
+ 'inspector.confirmedRes': 'Подтверждённое бронирование',
+ 'inspector.pendingRes': 'Ожидающее бронирование',
+ 'inspector.google': 'Открыть в Google Maps',
+ 'inspector.website': 'Открыть сайт',
+ 'inspector.addRes': 'Бронирование',
+ 'inspector.editRes': 'Редактировать бронирование',
+ 'inspector.participants': 'Участники',
+
+ // Reservations
+ 'reservations.title': 'Бронирования',
+ 'reservations.empty': 'Пока нет бронирований',
+ 'reservations.emptyHint': 'Добавьте бронирования на авиабилеты, отели и другое',
+ 'reservations.add': 'Добавить бронирование',
+ 'reservations.addManual': 'Ручное бронирование',
+ 'reservations.placeHint': 'Совет: бронирования лучше создавать прямо из места, чтобы связать их с планом дня.',
+ 'reservations.confirmed': 'Подтверждено',
+ 'reservations.pending': 'Ожидание',
+ 'reservations.summary': '{confirmed} подтв., {pending} ожид.',
+ 'reservations.fromPlan': 'Из плана',
+ 'reservations.showFiles': 'Показать файлы',
+ 'reservations.editTitle': 'Редактировать бронирование',
+ 'reservations.status': 'Статус',
+ 'reservations.datetime': 'Дата и время',
+ 'reservations.startTime': 'Время начала',
+ 'reservations.endTime': 'Время окончания',
+ 'reservations.date': 'Дата',
+ 'reservations.time': 'Время',
+ 'reservations.timeAlt': 'Время (альтернативное, напр. 19:30)',
+ 'reservations.notes': 'Заметки',
+ 'reservations.notesPlaceholder': 'Дополнительные заметки...',
+ 'reservations.meta.airline': 'Авиакомпания',
+ 'reservations.meta.flightNumber': 'Номер рейса',
+ 'reservations.meta.from': 'Откуда',
+ 'reservations.meta.to': 'Куда',
+ 'reservations.meta.trainNumber': 'Номер поезда',
+ 'reservations.meta.platform': 'Платформа',
+ 'reservations.meta.seat': 'Место',
+ 'reservations.meta.checkIn': 'Заезд',
+ 'reservations.meta.checkOut': 'Выезд',
+ 'reservations.meta.linkAccommodation': 'Жильё',
+ 'reservations.meta.pickAccommodation': 'Привязать к жилью',
+ 'reservations.meta.noAccommodation': 'Нет',
+ 'reservations.meta.hotelPlace': 'Отель',
+ 'reservations.meta.pickHotel': 'Выбрать отель',
+ 'reservations.meta.fromDay': 'С',
+ 'reservations.meta.toDay': 'По',
+ 'reservations.meta.selectDay': 'Выбрать день',
+ 'reservations.type.flight': 'Авиабилет',
+ 'reservations.type.hotel': 'Отель',
+ 'reservations.type.restaurant': 'Ресторан',
+ 'reservations.type.train': 'Поезд',
+ 'reservations.type.car': 'Аренда авто',
+ 'reservations.type.cruise': 'Круиз',
+ 'reservations.type.event': 'Мероприятие',
+ 'reservations.type.tour': 'Экскурсия',
+ 'reservations.type.other': 'Другое',
+ 'reservations.confirm.delete': 'Вы уверены, что хотите удалить бронирование «{name}»?',
+ 'reservations.toast.updated': 'Бронирование обновлено',
+ 'reservations.toast.removed': 'Бронирование удалено',
+ 'reservations.toast.fileUploaded': 'Файл загружен',
+ 'reservations.toast.uploadError': 'Ошибка загрузки',
+ 'reservations.newTitle': 'Новое бронирование',
+ 'reservations.bookingType': 'Тип бронирования',
+ 'reservations.titleLabel': 'Название',
+ 'reservations.titlePlaceholder': 'напр. Lufthansa LH123, Hotel Adlon, ...',
+ 'reservations.locationAddress': 'Местоположение / Адрес',
+ 'reservations.locationPlaceholder': 'Адрес, аэропорт, отель...',
+ 'reservations.confirmationCode': 'Код бронирования',
+ 'reservations.confirmationPlaceholder': 'напр. ABC12345',
+ 'reservations.day': 'День',
+ 'reservations.noDay': 'Без дня',
+ 'reservations.place': 'Место',
+ 'reservations.noPlace': 'Без места',
+ 'reservations.pendingSave': 'будет сохранено…',
+ 'reservations.uploading': 'Загрузка...',
+ 'reservations.attachFile': 'Прикрепить файл',
+ 'reservations.toast.saveError': 'Ошибка сохранения',
+ 'reservations.toast.updateError': 'Ошибка обновления',
+ 'reservations.toast.deleteError': 'Ошибка удаления',
+ 'reservations.confirm.remove': 'Удалить бронирование для «{name}»?',
+ 'reservations.linkAssignment': 'Привязать к назначению дня',
+ 'reservations.pickAssignment': 'Выберите назначение из вашего плана...',
+ 'reservations.noAssignment': 'Без привязки (самостоятельное)',
+
+ // Budget
+ 'budget.title': 'Бюджет',
+ 'budget.emptyTitle': 'Бюджет ещё не создан',
+ 'budget.emptyText': 'Создайте категории и записи для планирования бюджета поездки',
+ 'budget.emptyPlaceholder': 'Введите название категории...',
+ 'budget.createCategory': 'Создать категорию',
+ 'budget.category': 'Категория',
+ 'budget.categoryName': 'Название категории',
+ 'budget.table.name': 'Название',
+ 'budget.table.total': 'Итого',
+ 'budget.table.persons': 'Человек',
+ 'budget.table.days': 'Дней',
+ 'budget.table.perPerson': 'На человека',
+ 'budget.table.perDay': 'В день',
+ 'budget.table.perPersonDay': 'Чел. / день',
+ 'budget.table.note': 'Заметка',
+ 'budget.newEntry': 'Новая запись',
+ 'budget.defaultEntry': 'Новая запись',
+ 'budget.defaultCategory': 'Новая категория',
+ 'budget.total': 'Итого',
+ 'budget.totalBudget': 'Общий бюджет',
+ 'budget.byCategory': 'По категориям',
+ 'budget.editTooltip': 'Нажмите для редактирования',
+ 'budget.confirm.deleteCategory': 'Вы уверены, что хотите удалить категорию «{name}» с {count} записями?',
+ 'budget.deleteCategory': 'Удалить категорию',
+ 'budget.perPerson': 'На человека',
+ 'budget.paid': 'Оплачено',
+ 'budget.open': 'Не оплачено',
+ 'budget.noMembers': 'Участники не назначены',
+
+ // Files
+ 'files.title': 'Файлы',
+ 'files.count': '{count} файлов',
+ 'files.countSingular': '1 файл',
+ 'files.uploaded': '{count} загружено',
+ 'files.uploadError': 'Ошибка загрузки',
+ 'files.dropzone': 'Перетащите файлы сюда',
+ 'files.dropzoneHint': 'или нажмите для выбора',
+ 'files.allowedTypes': 'Изображения, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Макс. 50 МБ',
+ 'files.uploading': 'Загрузка...',
+ 'files.filterAll': 'Все',
+ 'files.filterPdf': 'PDF',
+ 'files.filterImages': 'Изображения',
+ 'files.filterDocs': 'Документы',
+ 'files.filterCollab': 'Заметки Collab',
+ 'files.sourceCollab': 'Из заметок Collab',
+ 'files.empty': 'Файлов пока нет',
+ 'files.emptyHint': 'Загрузите файлы, чтобы прикрепить их к поездке',
+ 'files.openTab': 'Открыть в новой вкладке',
+ 'files.confirm.delete': 'Вы уверены, что хотите удалить этот файл?',
+ 'files.toast.deleted': 'Файл удалён',
+ 'files.toast.deleteError': 'Не удалось удалить файл',
+ 'files.sourcePlan': 'План дня',
+ 'files.sourceBooking': 'Бронирование',
+ 'files.attach': 'Прикрепить',
+ 'files.pasteHint': 'Также можно вставить изображения из буфера обмена (Ctrl+V)',
+ 'files.trash': 'Корзина',
+ 'files.trashEmpty': 'Корзина пуста',
+ 'files.emptyTrash': 'Очистить корзину',
+ 'files.restore': 'Восстановить',
+ 'files.star': 'В избранное',
+ 'files.unstar': 'Из избранного',
+ 'files.assign': 'Назначить',
+ 'files.assignTitle': 'Назначить файл',
+ 'files.assignPlace': 'Место',
+ 'files.assignBooking': 'Бронирование',
+ 'files.unassigned': 'Не назначен',
+ 'files.unlink': 'Удалить связь',
+ 'files.toast.trashed': 'Перемещено в корзину',
+ 'files.toast.restored': 'Файл восстановлен',
+ 'files.toast.trashEmptied': 'Корзина очищена',
+ 'files.toast.assigned': 'Файл назначен',
+ 'files.toast.assignError': 'Ошибка назначения',
+ 'files.toast.restoreError': 'Ошибка восстановления',
+ 'files.confirm.permanentDelete': 'Безвозвратно удалить этот файл? Это действие нельзя отменить.',
+ 'files.confirm.emptyTrash': 'Безвозвратно удалить все файлы из корзины? Это действие нельзя отменить.',
+ 'files.noteLabel': 'Заметка',
+ 'files.notePlaceholder': 'Добавить заметку...',
+
+ // Packing
+ 'packing.title': 'Список вещей',
+ 'packing.empty': 'Список вещей пуст',
+ 'packing.progress': '{packed} из {total} собрано ({percent}%)',
+ 'packing.clearChecked': 'Удалить {count} отмеченных',
+ 'packing.clearCheckedShort': 'Удалить {count}',
+ 'packing.suggestions': 'Подсказки',
+ 'packing.suggestionsTitle': 'Добавить подсказки',
+ 'packing.allSuggested': 'Все подсказки добавлены',
+ 'packing.allPacked': 'Всё собрано!',
+ 'packing.addPlaceholder': 'Добавить вещь...',
+ 'packing.categoryPlaceholder': 'Категория...',
+ 'packing.filterAll': 'Все',
+ 'packing.filterOpen': 'Не собрано',
+ 'packing.filterDone': 'Собрано',
+ 'packing.emptyTitle': 'Список вещей пуст',
+ 'packing.emptyHint': 'Добавьте вещи или используйте подсказки',
+ 'packing.emptyFiltered': 'Нет вещей, соответствующих фильтру',
+ 'packing.menuRename': 'Переименовать',
+ 'packing.menuCheckAll': 'Отметить все',
+ 'packing.menuUncheckAll': 'Снять отметки',
+ 'packing.menuDeleteCat': 'Удалить категорию',
+ 'packing.changeCategory': 'Изменить категорию',
+ 'packing.confirm.clearChecked': 'Вы уверены, что хотите удалить {count} отмеченных вещей?',
+ 'packing.confirm.deleteCat': 'Вы уверены, что хотите удалить категорию «{name}» с {count} вещами?',
+ 'packing.defaultCategory': 'Другое',
+ 'packing.toast.saveError': 'Ошибка сохранения',
+ 'packing.toast.deleteError': 'Ошибка удаления',
+ 'packing.toast.renameError': 'Ошибка переименования',
+ 'packing.toast.addError': 'Ошибка добавления',
+
+ // Packing suggestions
+ 'packing.suggestions.items': [
+ { name: 'Паспорт', category: 'Документы' },
+ { name: 'Удостоверение личности', category: 'Документы' },
+ { name: 'Страховка', category: 'Документы' },
+ { name: 'Авиабилеты', category: 'Документы' },
+ { name: 'Банковская карта', category: 'Финансы' },
+ { name: 'Наличные', category: 'Финансы' },
+ { name: 'Виза', category: 'Документы' },
+ { name: 'Футболки', category: 'Одежда' },
+ { name: 'Брюки', category: 'Одежда' },
+ { name: 'Нижнее бельё', category: 'Одежда' },
+ { name: 'Носки', category: 'Одежда' },
+ { name: 'Куртка', category: 'Одежда' },
+ { name: 'Пижама', category: 'Одежда' },
+ { name: 'Купальник', category: 'Одежда' },
+ { name: 'Дождевик', category: 'Одежда' },
+ { name: 'Удобная обувь', category: 'Одежда' },
+ { name: 'Зубная щётка', category: 'Гигиена' },
+ { name: 'Зубная паста', category: 'Гигиена' },
+ { name: 'Шампунь', category: 'Гигиена' },
+ { name: 'Дезодорант', category: 'Гигиена' },
+ { name: 'Солнцезащитный крем', category: 'Гигиена' },
+ { name: 'Бритва', category: 'Гигиена' },
+ { name: 'Зарядное устройство', category: 'Электроника' },
+ { name: 'Внешний аккумулятор', category: 'Электроника' },
+ { name: 'Наушники', category: 'Электроника' },
+ { name: 'Адаптер для розеток', category: 'Электроника' },
+ { name: 'Фотоаппарат', category: 'Электроника' },
+ { name: 'Обезболивающее', category: 'Здоровье' },
+ { name: 'Пластыри', category: 'Здоровье' },
+ { name: 'Антисептик', category: 'Здоровье' },
+ ],
+
+ // Members / Sharing
+ 'members.shareTrip': 'Поделиться поездкой',
+ 'members.inviteUser': 'Пригласить пользователя',
+ 'members.selectUser': 'Выберите пользователя…',
+ 'members.invite': 'Пригласить',
+ 'members.allHaveAccess': 'У всех пользователей уже есть доступ.',
+ 'members.access': 'Доступ',
+ 'members.person': 'человек',
+ 'members.persons': 'человек',
+ 'members.you': 'вы',
+ 'members.owner': 'Владелец',
+ 'members.leaveTrip': 'Покинуть поездку',
+ 'members.removeAccess': 'Отозвать доступ',
+ 'members.confirmLeave': 'Покинуть поездку? Вы потеряете доступ.',
+ 'members.confirmRemove': 'Отозвать доступ у этого пользователя?',
+ 'members.loadError': 'Не удалось загрузить участников',
+ 'members.added': 'добавлен',
+ 'members.addError': 'Ошибка добавления',
+ 'members.removed': 'Участник удалён',
+ 'members.removeError': 'Ошибка удаления',
+
+ // Categories (Admin)
+ 'categories.title': 'Категории',
+ 'categories.subtitle': 'Управление категориями мест',
+ 'categories.new': 'Новая категория',
+ 'categories.empty': 'Категорий пока нет',
+ 'categories.namePlaceholder': 'Название категории',
+ 'categories.icon': 'Иконка',
+ 'categories.color': 'Цвет',
+ 'categories.customColor': 'Выбрать свой цвет',
+ 'categories.preview': 'Предпросмотр',
+ 'categories.defaultName': 'Категория',
+ 'categories.update': 'Обновить',
+ 'categories.create': 'Создать',
+ 'categories.confirm.delete': 'Удалить категорию? Места в этой категории не будут удалены.',
+ 'categories.toast.loadError': 'Не удалось загрузить категории',
+ 'categories.toast.nameRequired': 'Введите название',
+ 'categories.toast.updated': 'Категория обновлена',
+ 'categories.toast.created': 'Категория создана',
+ 'categories.toast.saveError': 'Ошибка сохранения',
+ 'categories.toast.deleted': 'Категория удалена',
+ 'categories.toast.deleteError': 'Ошибка удаления',
+
+ // Backup (Admin)
+ 'backup.title': 'Резервная копия',
+ 'backup.subtitle': 'База данных и все загруженные файлы',
+ 'backup.refresh': 'Обновить',
+ 'backup.upload': 'Загрузить копию',
+ 'backup.uploading': 'Загрузка…',
+ 'backup.create': 'Создать копию',
+ 'backup.creating': 'Создание…',
+ 'backup.empty': 'Резервных копий нет',
+ 'backup.createFirst': 'Создать первую копию',
+ 'backup.download': 'Скачать',
+ 'backup.restore': 'Восстановить',
+ 'backup.confirm.restore': 'Восстановить копию «{name}»?\n\nВсе текущие данные будут заменены данными из копии.',
+ 'backup.confirm.uploadRestore': 'Загрузить и восстановить файл копии «{name}»?\n\nВсе текущие данные будут перезаписаны.',
+ 'backup.confirm.delete': 'Удалить копию «{name}»?',
+ 'backup.toast.loadError': 'Не удалось загрузить резервные копии',
+ 'backup.toast.created': 'Резервная копия создана',
+ 'backup.toast.createError': 'Не удалось создать резервную копию',
+ 'backup.toast.restored': 'Копия восстановлена. Страница перезагрузится…',
+ 'backup.toast.restoreError': 'Ошибка восстановления',
+ 'backup.toast.uploadError': 'Ошибка загрузки',
+ 'backup.toast.deleted': 'Резервная копия удалена',
+ 'backup.toast.deleteError': 'Ошибка удаления',
+ 'backup.toast.downloadError': 'Ошибка скачивания',
+ 'backup.toast.settingsSaved': 'Настройки автокопирования сохранены',
+ 'backup.toast.settingsError': 'Не удалось сохранить настройки',
+ 'backup.auto.title': 'Автокопирование',
+ 'backup.auto.subtitle': 'Автоматическое резервное копирование по расписанию',
+ 'backup.auto.enable': 'Включить автокопирование',
+ 'backup.auto.enableHint': 'Резервные копии будут создаваться автоматически по выбранному расписанию',
+ 'backup.auto.interval': 'Интервал',
+ 'backup.auto.keepLabel': 'Удалять старые копии через',
+ 'backup.interval.hourly': 'Каждый час',
+ 'backup.interval.daily': 'Ежедневно',
+ 'backup.interval.weekly': 'Еженедельно',
+ 'backup.interval.monthly': 'Ежемесячно',
+ 'backup.keep.1day': '1 день',
+ 'backup.keep.3days': '3 дня',
+ 'backup.keep.7days': '7 дней',
+ 'backup.keep.14days': '14 дней',
+ 'backup.keep.30days': '30 дней',
+ 'backup.keep.forever': 'Хранить вечно',
+
+ // Photos
+ 'photos.allDays': 'Все дни',
+ 'photos.noPhotos': 'Фото пока нет',
+ 'photos.uploadHint': 'Загрузите фото из путешествия',
+ 'photos.clickToSelect': 'или нажмите для выбора',
+ 'photos.linkPlace': 'Привязать место',
+ 'photos.noPlace': 'Без места',
+ 'photos.uploadN': '{n} фото загружено',
+
+ // Backup restore modal
+ 'backup.restoreConfirmTitle': 'Восстановить копию?',
+ 'backup.restoreWarning': 'Все текущие данные (поездки, места, пользователи, загрузки) будут безвозвратно заменены данными из копии. Это действие нельзя отменить.',
+ 'backup.restoreTip': 'Совет: создайте резервную копию текущего состояния перед восстановлением.',
+ 'backup.restoreConfirm': 'Да, восстановить',
+
+ // PDF
+ 'pdf.travelPlan': 'План поездки',
+ 'pdf.planned': 'Запланировано',
+ 'pdf.costLabel': 'Стоимость EUR',
+ 'pdf.preview': 'Предпросмотр PDF',
+ 'pdf.saveAsPdf': 'Сохранить как PDF',
+
+ // Planner
+ 'planner.places': 'Места',
+ 'planner.bookings': 'Бронирования',
+ 'planner.packingList': 'Список вещей',
+ 'planner.documents': 'Документы',
+ 'planner.dayPlan': 'План дня',
+ 'planner.reservations': 'Бронирования',
+ 'planner.minTwoPlaces': 'Нужно минимум 2 места с координатами',
+ 'planner.noGeoPlaces': 'Нет мест с координатами',
+ 'planner.routeCalculated': 'Маршрут рассчитан',
+ 'planner.routeCalcFailed': 'Не удалось рассчитать маршрут',
+ 'planner.routeError': 'Ошибка расчёта маршрута',
+ 'planner.routeOptimized': 'Маршрут оптимизирован',
+ 'planner.reservationUpdated': 'Бронирование обновлено',
+ 'planner.reservationAdded': 'Бронирование добавлено',
+ 'planner.confirmDeleteReservation': 'Удалить бронирование?',
+ 'planner.reservationDeleted': 'Бронирование удалено',
+ 'planner.days': 'Дни',
+ 'planner.allPlaces': 'Все места',
+ 'planner.totalPlaces': 'Всего {n} мест',
+ 'planner.noDaysPlanned': 'Дни ещё не запланированы',
+ 'planner.editTrip': 'Редактировать поездку \u2192',
+ 'planner.placeOne': '1 место',
+ 'planner.placeN': '{n} мест',
+ 'planner.addNote': 'Добавить заметку',
+ 'planner.noEntries': 'На этот день записей нет',
+ 'planner.addPlace': 'Добавить место/активность',
+ 'planner.addPlaceShort': '+ Добавить место/активность',
+ 'planner.resPending': 'Бронирование ожидает · ',
+ 'planner.resConfirmed': 'Бронирование подтверждено · ',
+ 'planner.notePlaceholder': 'Заметка…',
+ 'planner.noteTimePlaceholder': 'Время (необязательно)',
+ 'planner.noteExamplePlaceholder': 'напр. S3 в 14:30 с вокзала, паром с причала 7, обеденный перерыв…',
+ 'planner.totalCost': 'Общая стоимость',
+ 'planner.searchPlaces': 'Поиск мест…',
+ 'planner.allCategories': 'Все категории',
+ 'planner.noPlacesFound': 'Места не найдены',
+ 'planner.addFirstPlace': 'Добавить первое место',
+ 'planner.noReservations': 'Нет бронирований',
+ 'planner.addFirstReservation': 'Добавить первое бронирование',
+ 'planner.new': 'Новое',
+ 'planner.addToDay': '+ День',
+ 'planner.calculating': 'Расчёт…',
+ 'planner.route': 'Маршрут',
+ 'planner.optimize': 'Оптимизировать',
+ 'planner.openGoogleMaps': 'Открыть в Google Maps',
+ 'planner.selectDayHint': 'Выберите день из списка слева для просмотра плана дня',
+ 'planner.noPlacesForDay': 'На этот день мест пока нет',
+ 'planner.addPlacesLink': 'Добавить места \u2192',
+ 'planner.minTotal': 'мин. всего',
+ 'planner.noReservation': 'Нет бронирования',
+ 'planner.removeFromDay': 'Убрать из дня',
+ 'planner.addToThisDay': 'Добавить в день',
+ 'planner.overview': 'Обзор',
+ 'planner.noDays': 'Дней нет',
+ 'planner.editTripToAddDays': 'Отредактируйте поездку для добавления дней',
+ 'planner.dayCount': '{n} дней',
+ 'planner.clickToUnlock': 'Нажмите для разблокировки',
+ 'planner.keepPosition': 'Сохранить позицию при оптимизации маршрута',
+ 'planner.dayDetails': 'Подробности дня',
+ 'planner.dayN': 'День {n}',
+
+ // Dashboard Stats
+ 'stats.countries': 'Страны',
+ 'stats.cities': 'Города',
+ 'stats.trips': 'Поездки',
+ 'stats.places': 'Места',
+ 'stats.worldProgress': 'Прогресс по миру',
+ 'stats.visited': 'посещено',
+ 'stats.remaining': 'осталось',
+ 'stats.visitedCountries': 'Посещённые страны',
+
+ // Day Detail Panel
+ 'day.precipProb': 'Вероятность осадков',
+ 'day.precipitation': 'Осадки',
+ 'day.wind': 'Ветер',
+ 'day.sunrise': 'Восход',
+ 'day.sunset': 'Закат',
+ 'day.hourlyForecast': 'Почасовой прогноз',
+ 'day.climateHint': 'Исторические средние — реальный прогноз доступен за 16 дней до этой даты.',
+ 'day.noWeather': 'Данные о погоде недоступны. Добавьте место с координатами.',
+ 'day.overview': 'Обзор дня',
+ 'day.accommodation': 'Жильё',
+ 'day.addAccommodation': 'Добавить жильё',
+ 'day.hotelDayRange': 'Применить к дням',
+ 'day.noPlacesForHotel': 'Сначала добавьте места в поездку',
+ 'day.allDays': 'Все',
+ 'day.checkIn': 'Заезд',
+ 'day.checkOut': 'Выезд',
+ 'day.confirmation': 'Подтверждение',
+ 'day.editAccommodation': 'Редактировать жильё',
+ 'day.reservations': 'Бронирования',
+
+ // Collab Addon
+ 'collab.tabs.chat': 'Чат',
+ 'collab.tabs.notes': 'Заметки',
+ 'collab.tabs.polls': 'Опросы',
+ 'collab.whatsNext.title': 'Что дальше',
+ 'collab.whatsNext.today': 'Сегодня',
+ 'collab.whatsNext.tomorrow': 'Завтра',
+ 'collab.whatsNext.empty': 'Нет предстоящих активностей',
+ 'collab.whatsNext.until': 'до',
+ 'collab.whatsNext.emptyHint': 'Активности со временем будут отображаться здесь',
+ 'collab.chat.send': 'Отправить',
+ 'collab.chat.placeholder': 'Введите сообщение...',
+ 'collab.chat.empty': 'Начните разговор',
+ 'collab.chat.emptyHint': 'Сообщения видны всем участникам поездки',
+ 'collab.chat.emptyDesc': 'Делитесь идеями, планами и новостями с вашей группой',
+ 'collab.chat.today': 'Сегодня',
+ 'collab.chat.yesterday': 'Вчера',
+ 'collab.chat.deletedMessage': 'удалил(а) сообщение',
+ 'collab.chat.loadMore': 'Загрузить старые сообщения',
+ 'collab.chat.justNow': 'только что',
+ 'collab.chat.minutesAgo': '{n} мин. назад',
+ 'collab.chat.hoursAgo': '{n} ч. назад',
+ 'collab.notes.title': 'Заметки',
+ 'collab.notes.new': 'Новая заметка',
+ 'collab.notes.empty': 'Заметок пока нет',
+ 'collab.notes.emptyHint': 'Начните записывать идеи и планы',
+ 'collab.notes.all': 'Все',
+ 'collab.notes.titlePlaceholder': 'Название заметки',
+ 'collab.notes.contentPlaceholder': 'Напишите что-нибудь...',
+ 'collab.notes.categoryPlaceholder': 'Категория',
+ 'collab.notes.newCategory': 'Новая категория...',
+ 'collab.notes.category': 'Категория',
+ 'collab.notes.noCategory': 'Без категории',
+ 'collab.notes.color': 'Цвет',
+ 'collab.notes.save': 'Сохранить',
+ 'collab.notes.cancel': 'Отмена',
+ 'collab.notes.edit': 'Редактировать',
+ 'collab.notes.delete': 'Удалить',
+ 'collab.notes.pin': 'Закрепить',
+ 'collab.notes.unpin': 'Открепить',
+ 'collab.notes.daysAgo': '{n} дн. назад',
+ 'collab.notes.categorySettings': 'Управление категориями',
+ 'collab.notes.create': 'Создать',
+ 'collab.notes.website': 'Сайт',
+ 'collab.notes.websitePlaceholder': 'https://...',
+ 'collab.notes.attachFiles': 'Прикрепить файлы',
+ 'collab.notes.noCategoriesYet': 'Категорий пока нет',
+ 'collab.notes.emptyDesc': 'Создайте заметку, чтобы начать',
+ 'collab.polls.title': 'Опросы',
+ 'collab.polls.new': 'Новый опрос',
+ 'collab.polls.empty': 'Опросов пока нет',
+ 'collab.polls.emptyHint': 'Задайте вопрос группе и голосуйте вместе',
+ 'collab.polls.question': 'Вопрос',
+ 'collab.polls.questionPlaceholder': 'Что нам делать?',
+ 'collab.polls.addOption': '+ Добавить вариант',
+ 'collab.polls.optionPlaceholder': 'Вариант {n}',
+ 'collab.polls.create': 'Создать опрос',
+ 'collab.polls.close': 'Закрыть',
+ 'collab.polls.closed': 'Закрыт',
+ 'collab.polls.votes': '{n} голосов',
+ 'collab.polls.vote': '{n} голос',
+ 'collab.polls.multipleChoice': 'Множественный выбор',
+ 'collab.polls.multiChoice': 'Множественный выбор',
+ 'collab.polls.deadline': 'Срок',
+ 'collab.polls.option': 'Вариант',
+ 'collab.polls.options': 'Варианты',
+ 'collab.polls.delete': 'Удалить',
+ 'collab.polls.closedSection': 'Закрытые',
+}
+
+export default ru
diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts
new file mode 100644
index 0000000..8629767
--- /dev/null
+++ b/client/src/i18n/translations/zh.ts
@@ -0,0 +1,1082 @@
+const zh: Record = {
+ // Common
+ 'common.save': '保存',
+ 'common.cancel': '取消',
+ 'common.delete': '删除',
+ 'common.edit': '编辑',
+ 'common.add': '添加',
+ 'common.loading': '加载中...',
+ 'common.error': '错误',
+ 'common.back': '返回',
+ 'common.all': '全部',
+ 'common.close': '关闭',
+ 'common.open': '打开',
+ 'common.upload': '上传',
+ 'common.search': '搜索',
+ 'common.confirm': '确认',
+ 'common.ok': '确定',
+ 'common.yes': '是',
+ 'common.no': '否',
+ 'common.or': '或',
+ 'common.none': '无',
+ 'common.date': '日期',
+ 'common.rename': '重命名',
+ 'common.name': '名称',
+ 'common.email': '邮箱',
+ 'common.password': '密码',
+ 'common.saving': '保存中...',
+ 'common.update': '更新',
+ 'common.change': '修改',
+ 'common.uploading': '上传中…',
+ 'common.backToPlanning': '返回规划',
+ 'common.reset': '重置',
+
+ // Navbar
+ 'nav.trip': '旅行',
+ 'nav.share': '分享',
+ 'nav.settings': '设置',
+ 'nav.admin': '管理',
+ 'nav.logout': '退出登录',
+ 'nav.lightMode': '浅色模式',
+ 'nav.darkMode': '深色模式',
+ 'nav.autoMode': '自动模式',
+ 'nav.administrator': '管理员',
+
+ // Dashboard
+ 'dashboard.title': '我的旅行',
+ 'dashboard.subtitle.loading': '加载旅行中...',
+ 'dashboard.subtitle.trips': '{count} 次旅行({archived} 已归档)',
+ 'dashboard.subtitle.empty': '开始你的第一次旅行',
+ 'dashboard.subtitle.activeOne': '{count} 个进行中的旅行',
+ 'dashboard.subtitle.activeMany': '{count} 个进行中的旅行',
+ 'dashboard.subtitle.archivedSuffix': ' · {count} 已归档',
+ 'dashboard.newTrip': '新建旅行',
+ 'dashboard.currency': '货币',
+ 'dashboard.timezone': '时区',
+ 'dashboard.localTime': '本地',
+ 'dashboard.timezoneCustomTitle': '自定义时区',
+ 'dashboard.timezoneCustomLabelPlaceholder': '标签(可选)',
+ 'dashboard.timezoneCustomTzPlaceholder': '如 America/New_York',
+ 'dashboard.timezoneCustomAdd': '添加',
+ 'dashboard.timezoneCustomErrorEmpty': '请输入时区标识符',
+ 'dashboard.timezoneCustomErrorInvalid': '无效的时区。请使用 Europe/Berlin 这样的格式',
+ 'dashboard.timezoneCustomErrorDuplicate': '已添加',
+ 'dashboard.emptyTitle': '暂无旅行',
+ 'dashboard.emptyText': '创建你的第一次旅行,开始规划吧!',
+ 'dashboard.emptyButton': '创建第一次旅行',
+ 'dashboard.nextTrip': '下次旅行',
+ 'dashboard.shared': '共享',
+ 'dashboard.sharedBy': '由 {name} 分享',
+ 'dashboard.days': '天',
+ 'dashboard.places': '地点',
+ 'dashboard.archive': '归档',
+ 'dashboard.restore': '恢复',
+ 'dashboard.archived': '已归档',
+ 'dashboard.status.ongoing': '进行中',
+ 'dashboard.status.today': '今天',
+ 'dashboard.status.tomorrow': '明天',
+ 'dashboard.status.past': '已结束',
+ 'dashboard.status.daysLeft': '还剩 {count} 天',
+ 'dashboard.toast.loadError': '加载旅行失败',
+ 'dashboard.toast.created': '旅行创建成功!',
+ 'dashboard.toast.createError': '创建旅行失败',
+ 'dashboard.toast.updated': '旅行已更新!',
+ 'dashboard.toast.updateError': '更新旅行失败',
+ 'dashboard.toast.deleted': '旅行已删除',
+ 'dashboard.toast.deleteError': '删除旅行失败',
+ 'dashboard.toast.archived': '旅行已归档',
+ 'dashboard.toast.archiveError': '归档旅行失败',
+ 'dashboard.toast.restored': '旅行已恢复',
+ 'dashboard.toast.restoreError': '恢复旅行失败',
+ 'dashboard.confirm.delete': '删除旅行「{title}」?所有地点和计划将被永久删除。',
+ 'dashboard.editTrip': '编辑旅行',
+ 'dashboard.createTrip': '创建新旅行',
+ 'dashboard.tripTitle': '标题',
+ 'dashboard.tripTitlePlaceholder': '如:日本夏日之旅',
+ 'dashboard.tripDescription': '描述',
+ 'dashboard.tripDescriptionPlaceholder': '这次旅行是关于什么的?',
+ 'dashboard.startDate': '开始日期',
+ 'dashboard.endDate': '结束日期',
+ 'dashboard.noDateHint': '未设置日期——将默认创建 7 天。你可以随时修改。',
+ 'dashboard.coverImage': '封面图片',
+ 'dashboard.addCoverImage': '添加封面图片',
+ 'dashboard.coverSaved': '封面图片已保存',
+ 'dashboard.coverUploadError': '上传失败',
+ 'dashboard.coverRemoveError': '移除失败',
+ 'dashboard.titleRequired': '标题为必填项',
+ 'dashboard.endDateError': '结束日期必须晚于开始日期',
+
+ // Settings
+ 'settings.title': '设置',
+ 'settings.subtitle': '配置你的个人设置',
+ 'settings.map': '地图',
+ 'settings.mapTemplate': '地图模板',
+ 'settings.mapTemplatePlaceholder.select': '选择模板...',
+ 'settings.mapDefaultHint': '留空则使用 OpenStreetMap(默认)',
+ 'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
+ 'settings.mapHint': '地图瓦片 URL 模板',
+ 'settings.latitude': '纬度',
+ 'settings.longitude': '经度',
+ 'settings.saveMap': '保存地图',
+ 'settings.apiKeys': 'API 密钥',
+ 'settings.mapsKey': 'Google Maps API 密钥',
+ 'settings.mapsKeyHint': '用于地点搜索。需要 Places API (New)。在 console.cloud.google.com 获取',
+ 'settings.weatherKey': 'OpenWeatherMap API 密钥',
+ 'settings.weatherKeyHint': '用于天气数据。在 openweathermap.org/api 免费获取',
+ 'settings.keyPlaceholder': '输入密钥...',
+ 'settings.configured': '已配置',
+ 'settings.saveKeys': '保存密钥',
+ 'settings.display': '显示',
+ 'settings.colorMode': '颜色模式',
+ 'settings.light': '浅色',
+ 'settings.dark': '深色',
+ 'settings.auto': '自动',
+ 'settings.language': '语言',
+ 'settings.temperature': '温度单位',
+ 'settings.timeFormat': '时间格式',
+ 'settings.routeCalculation': '路线计算',
+ 'settings.on': '开',
+ 'settings.off': '关',
+ 'settings.account': '账户',
+ 'settings.username': '用户名',
+ 'settings.email': '邮箱',
+ 'settings.role': '角色',
+ 'settings.roleAdmin': '管理员',
+ 'settings.oidcLinked': '已关联',
+ 'settings.changePassword': '修改密码',
+ 'settings.currentPassword': '当前密码',
+ 'settings.currentPasswordRequired': '请输入当前密码',
+ 'settings.newPassword': '新密码',
+ 'settings.confirmPassword': '确认新密码',
+ 'settings.updatePassword': '更新密码',
+ 'settings.passwordRequired': '请输入当前密码和新密码',
+ 'settings.passwordTooShort': '密码至少需要 8 个字符',
+ 'settings.passwordMismatch': '两次输入的密码不一致',
+ 'settings.passwordWeak': '密码必须包含大写字母、小写字母和数字',
+ 'settings.passwordChanged': '密码修改成功',
+ 'settings.deleteAccount': '删除账户',
+ 'settings.deleteAccountTitle': '确定删除账户?',
+ 'settings.deleteAccountWarning': '你的账户以及所有旅行、地点和文件将被永久删除。此操作无法撤销。',
+ 'settings.deleteAccountConfirm': '永久删除',
+ 'settings.deleteBlockedTitle': '无法删除',
+ 'settings.deleteBlockedMessage': '你是唯一的管理员。请先将其他用户提升为管理员,然后再删除账户。',
+ 'settings.roleUser': '用户',
+ 'settings.saveProfile': '保存资料',
+ 'settings.toast.mapSaved': '地图设置已保存',
+ 'settings.toast.keysSaved': 'API 密钥已保存',
+ 'settings.toast.displaySaved': '显示设置已保存',
+ 'settings.toast.profileSaved': '资料已保存',
+ 'settings.uploadAvatar': '上传头像',
+ 'settings.removeAvatar': '移除头像',
+ 'settings.avatarUploaded': '头像已更新',
+ 'settings.avatarRemoved': '头像已移除',
+ 'settings.avatarError': '上传失败',
+
+ // Login
+ 'login.error': '登录失败,请检查你的凭据。',
+ 'login.tagline': '你的旅行。\n你的计划。',
+ 'login.description': '通过互动地图、预算管理和实时同步,协同规划旅行。',
+ 'login.features.maps': '互动地图',
+ 'login.features.mapsDesc': 'Google Places、路线和聚类',
+ 'login.features.realtime': '实时同步',
+ 'login.features.realtimeDesc': '通过 WebSocket 协同规划',
+ 'login.features.budget': '预算跟踪',
+ 'login.features.budgetDesc': '分类、图表和人均费用',
+ 'login.features.collab': '协作',
+ 'login.features.collabDesc': '多用户共享旅行',
+ 'login.features.packing': '行李清单',
+ 'login.features.packingDesc': '分类、进度和建议',
+ 'login.features.bookings': '预订',
+ 'login.features.bookingsDesc': '航班、酒店、餐厅等',
+ 'login.features.files': '文档',
+ 'login.features.filesDesc': '上传和管理文档',
+ 'login.features.routes': '智能路线',
+ 'login.features.routesDesc': '自动优化和导出到 Google Maps',
+ 'login.selfHosted': '自托管 · 开源 · 数据由你掌控',
+ 'login.title': '登录',
+ 'login.subtitle': '欢迎回来',
+ 'login.signingIn': '登录中…',
+ 'login.signIn': '登录',
+ 'login.createAdmin': '创建管理员账户',
+ 'login.createAdminHint': '为 TREK 设置第一个管理员账户。',
+ 'login.createAccount': '创建账户',
+ 'login.createAccountHint': '注册新账户。',
+ 'login.creating': '创建中…',
+ 'login.noAccount': '还没有账户?',
+ 'login.hasAccount': '已有账户?',
+ 'login.register': '注册',
+ 'login.emailPlaceholder': 'your@email.com',
+ 'login.username': '用户名',
+ 'login.oidc.registrationDisabled': '注册已关闭。请联系管理员。',
+ 'login.oidc.noEmail': '未从提供商获取到邮箱。',
+ 'login.oidc.tokenFailed': '认证失败。',
+ 'login.oidc.invalidState': '会话无效,请重试。',
+ 'login.demoFailed': '演示登录失败',
+ 'login.oidcSignIn': '通过 {name} 登录',
+ 'login.oidcOnly': '密码登录已关闭。请通过 SSO 提供商登录。',
+ 'login.demoHint': '试用演示——无需注册',
+
+ // Register
+ 'register.passwordMismatch': '两次输入的密码不一致',
+ 'register.passwordTooShort': '密码至少需要 6 个字符',
+ 'register.failed': '注册失败',
+ 'register.getStarted': '开始使用',
+ 'register.subtitle': '创建账户,开始规划你的梦想旅行。',
+ 'register.feature1': '无限旅行计划',
+ 'register.feature2': '互动地图视图',
+ 'register.feature3': '管理地点和分类',
+ 'register.feature4': '跟踪预订',
+ 'register.feature5': '创建行李清单',
+ 'register.feature6': '存储照片和文件',
+ 'register.createAccount': '创建账户',
+ 'register.startPlanning': '开始规划你的旅行',
+ 'register.minChars': '至少 6 个字符',
+ 'register.confirmPassword': '确认密码',
+ 'register.repeatPassword': '重复密码',
+ 'register.registering': '注册中...',
+ 'register.register': '注册',
+ 'register.hasAccount': '已有账户?',
+ 'register.signIn': '登录',
+
+ // Admin
+ 'admin.title': '管理后台',
+ 'admin.subtitle': '用户管理和系统设置',
+ 'admin.tabs.users': '用户',
+ 'admin.tabs.categories': '分类',
+ 'admin.tabs.backup': '备份',
+ 'admin.stats.users': '用户',
+ 'admin.stats.trips': '旅行',
+ 'admin.stats.places': '地点',
+ 'admin.stats.photos': '照片',
+ 'admin.stats.files': '文件',
+ 'admin.table.user': '用户',
+ 'admin.table.email': '邮箱',
+ 'admin.table.role': '角色',
+ 'admin.table.created': '创建时间',
+ 'admin.table.lastLogin': '最后登录',
+ 'admin.table.actions': '操作',
+ 'admin.you': '(你)',
+ 'admin.editUser': '编辑用户',
+ 'admin.newPassword': '新密码',
+ 'admin.newPasswordHint': '留空则保持当前密码',
+ 'admin.deleteUser': '删除用户「{name}」?所有旅行将被永久删除。',
+ 'admin.deleteUserTitle': '删除用户',
+ 'admin.newPasswordPlaceholder': '输入新密码…',
+ 'admin.toast.loadError': '加载管理数据失败',
+ 'admin.toast.userUpdated': '用户已更新',
+ 'admin.toast.updateError': '更新失败',
+ 'admin.toast.userDeleted': '用户已删除',
+ 'admin.toast.deleteError': '删除失败',
+ 'admin.toast.cannotDeleteSelf': '不能删除自己的账户',
+ 'admin.toast.userCreated': '用户已创建',
+ 'admin.toast.createError': '创建用户失败',
+ 'admin.toast.fieldsRequired': '用户名、邮箱和密码为必填项',
+ 'admin.createUser': '创建用户',
+ 'admin.tabs.settings': '设置',
+ 'admin.allowRegistration': '允许注册',
+ 'admin.allowRegistrationHint': '新用户可以自行注册',
+ 'admin.apiKeys': 'API 密钥',
+ 'admin.apiKeysHint': '可选。启用地点的扩展数据,如照片和天气。',
+ 'admin.mapsKey': 'Google Maps API 密钥',
+ 'admin.mapsKeyHint': '用于地点搜索。在 console.cloud.google.com 获取',
+ 'admin.mapsKeyHintLong': '没有 API 密钥时,使用 OpenStreetMap 搜索地点。有了 Google API 密钥,还可以加载照片、评分和营业时间。在 console.cloud.google.com 获取。',
+ 'admin.recommended': '推荐',
+ 'admin.weatherKey': 'OpenWeatherMap API 密钥',
+ 'admin.weatherKeyHint': '用于天气数据。在 openweathermap.org 免费获取',
+ 'admin.validateKey': '测试',
+ 'admin.keyValid': '已连接',
+ 'admin.keyInvalid': '无效',
+ 'admin.keySaved': 'API 密钥已保存',
+ 'admin.oidcTitle': '单点登录 (OIDC)',
+ 'admin.oidcSubtitle': '允许通过 Google、Apple、Authentik 或 Keycloak 等外部提供商登录。',
+ 'admin.oidcDisplayName': '显示名称',
+ 'admin.oidcIssuer': '颁发者 URL',
+ 'admin.oidcIssuerHint': '提供商的 OpenID Connect 颁发者 URL。如 https://accounts.google.com',
+ 'admin.oidcSaved': 'OIDC 配置已保存',
+ 'admin.oidcOnlyMode': '禁用密码登录',
+ 'admin.oidcOnlyModeHint': '启用后,仅允许 SSO 登录。密码登录和注册将被禁用。',
+
+ // File Types
+ 'admin.fileTypes': '允许的文件类型',
+ 'admin.fileTypesHint': '配置用户可以上传的文件类型。',
+ 'admin.fileTypesFormat': '以逗号分隔的扩展名(如 jpg,png,pdf,doc)。使用 * 允许所有类型。',
+ 'admin.fileTypesSaved': '文件类型设置已保存',
+
+ // Addons
+ 'admin.tabs.addons': '扩展',
+ 'admin.addons.title': '扩展',
+ 'admin.addons.subtitle': '启用或禁用功能以自定义你的 TREK 体验。',
+ 'admin.addons.catalog.memories.name': '回忆',
+ 'admin.addons.catalog.memories.description': '每次旅行的共享相册',
+ 'admin.addons.catalog.packing.name': '行李',
+ 'admin.addons.catalog.packing.description': '每次旅行的行李准备清单',
+ 'admin.addons.catalog.budget.name': '预算',
+ 'admin.addons.catalog.budget.description': '跟踪支出并规划旅行预算',
+ 'admin.addons.catalog.documents.name': '文档',
+ 'admin.addons.catalog.documents.description': '存储和管理旅行文档',
+ 'admin.addons.catalog.vacay.name': 'Vacay',
+ 'admin.addons.catalog.vacay.description': '带日历视图的个人假期规划器',
+ 'admin.addons.catalog.atlas.name': 'Atlas',
+ 'admin.addons.catalog.atlas.description': '标记已访问国家和旅行统计的世界地图',
+ 'admin.addons.catalog.collab.name': 'Collab',
+ 'admin.addons.catalog.collab.description': '旅行规划的实时笔记、投票和聊天',
+ 'admin.addons.subtitleBefore': '启用或禁用功能以自定义你的 ',
+ 'admin.addons.subtitleAfter': ' 体验。',
+ 'admin.addons.enabled': '已启用',
+ 'admin.addons.disabled': '已禁用',
+ 'admin.addons.type.trip': '旅行',
+ 'admin.addons.type.global': '全局',
+ 'admin.addons.tripHint': '在每次旅行中作为标签页显示',
+ 'admin.addons.globalHint': '在主导航中作为独立板块显示',
+ 'admin.addons.toast.updated': '扩展已更新',
+ 'admin.addons.toast.error': '更新扩展失败',
+ 'admin.addons.noAddons': '暂无可用扩展',
+ // Weather info
+ 'admin.weather.title': '天气数据',
+ 'admin.weather.badge': '自 2026 年 3 月 24 日起',
+ 'admin.weather.description': 'TREK 使用 Open-Meteo 作为天气数据源。Open-Meteo 是免费的开源天气服务——无需 API 密钥。',
+ 'admin.weather.forecast': '16 天天气预报',
+ 'admin.weather.forecastDesc': '之前为 5 天 (OpenWeatherMap)',
+ 'admin.weather.climate': '历史气候数据',
+ 'admin.weather.climateDesc': '16 天预报之外的日期使用过去 85 年的平均值',
+ 'admin.weather.requests': '每天 10,000 次请求',
+ 'admin.weather.requestsDesc': '免费,无需 API 密钥',
+ 'admin.weather.locationHint': '天气基于每天中第一个有坐标的地点。如果当天没有分配地点,则使用地点列表中的任意地点作为参考。',
+
+ // GitHub
+ 'admin.tabs.github': 'GitHub',
+ 'admin.github.title': '版本历史',
+ 'admin.github.subtitle': '{repo} 的最新更新',
+ 'admin.github.latest': '最新',
+ 'admin.github.prerelease': '预发布',
+ 'admin.github.showDetails': '显示详情',
+ 'admin.github.hideDetails': '隐藏详情',
+ 'admin.github.loadMore': '加载更多',
+ 'admin.github.loading': '加载中...',
+ 'admin.github.error': '加载版本失败',
+ 'admin.github.by': '作者',
+
+ 'admin.update.available': '有可用更新',
+ 'admin.update.text': 'TREK {version} 已发布。你当前使用的是 {current}。',
+ 'admin.update.button': '在 GitHub 查看',
+ 'admin.update.install': '安装更新',
+ 'admin.update.confirmTitle': '确定安装更新?',
+ 'admin.update.confirmText': 'TREK 将从 {current} 更新到 {version}。服务器将自动重启。',
+ 'admin.update.dataInfo': '你的所有数据(旅行、用户、API 密钥、上传文件、Vacay、Atlas、预算)将被保留。',
+ 'admin.update.warning': '重启期间应用将短暂不可用。',
+ 'admin.update.confirm': '立即更新',
+ 'admin.update.installing': '更新中…',
+ 'admin.update.success': '更新已安装!服务器正在重启…',
+ 'admin.update.failed': '更新失败',
+ 'admin.update.backupHint': '建议在更新前创建备份。',
+ 'admin.update.backupLink': '前往备份',
+ 'admin.update.howTo': '如何更新',
+ 'admin.update.dockerText': '你的 TREK 实例运行在 Docker 中。要更新到 {version},请在服务器上执行以下命令:',
+ 'admin.update.reloadHint': '请在几秒后刷新页面。',
+
+ // Vacay addon
+ 'vacay.subtitle': '规划和管理假期',
+ 'vacay.settings': '设置',
+ 'vacay.year': '年份',
+ 'vacay.addYear': '添加年份',
+ 'vacay.removeYear': '移除年份',
+ 'vacay.removeYearConfirm': '移除 {year}?',
+ 'vacay.removeYearHint': '该年度所有假期记录和公司假日将被永久删除。',
+ 'vacay.remove': '移除',
+ 'vacay.persons': '成员',
+ 'vacay.noPersons': '暂无成员',
+ 'vacay.addPerson': '添加成员',
+ 'vacay.editPerson': '编辑成员',
+ 'vacay.removePerson': '移除成员',
+ 'vacay.removePersonConfirm': '移除 {name}?',
+ 'vacay.removePersonHint': '该成员的所有假期记录将被永久删除。',
+ 'vacay.personName': '姓名',
+ 'vacay.personNamePlaceholder': '输入姓名',
+ 'vacay.color': '颜色',
+ 'vacay.add': '添加',
+ 'vacay.legend': '图例',
+ 'vacay.publicHoliday': '公共假日',
+ 'vacay.companyHoliday': '公司假日',
+ 'vacay.weekend': '周末',
+ 'vacay.modeVacation': '休假',
+ 'vacay.modeCompany': '公司假日',
+ 'vacay.entitlement': '年假额度',
+ 'vacay.entitlementDays': '天',
+ 'vacay.used': '已用',
+ 'vacay.remaining': '剩余',
+ 'vacay.carriedOver': '从 {year} 结转',
+ 'vacay.blockWeekends': '锁定周末',
+ 'vacay.blockWeekendsHint': '禁止在周六和周日安排假期',
+ 'vacay.publicHolidays': '公共假日',
+ 'vacay.publicHolidaysHint': '在日历中标记公共假日',
+ 'vacay.selectCountry': '选择国家',
+ 'vacay.selectRegion': '选择地区(可选)',
+ 'vacay.companyHolidays': '公司假日',
+ 'vacay.companyHolidaysHint': '允许标记公司统一休假日',
+ 'vacay.companyHolidaysNoDeduct': '公司假日不计入年假天数。',
+ 'vacay.carryOver': '结转',
+ 'vacay.carryOverHint': '自动将剩余年假天数结转到下一年',
+ 'vacay.sharing': '共享',
+ 'vacay.sharingHint': '与其他 TREK 用户共享你的假期计划',
+ 'vacay.owner': '所有者',
+ 'vacay.shareEmailPlaceholder': 'TREK 用户邮箱',
+ 'vacay.shareSuccess': '计划共享成功',
+ 'vacay.shareError': '无法共享计划',
+ 'vacay.dissolve': '解除合并',
+ 'vacay.dissolveHint': '重新分离日历。你的记录将被保留。',
+ 'vacay.dissolveAction': '解除',
+ 'vacay.dissolved': '日历已分离',
+ 'vacay.fusedWith': '已合并',
+ 'vacay.you': '你',
+ 'vacay.noData': '暂无数据',
+ 'vacay.changeColor': '更改颜色',
+ 'vacay.inviteUser': '邀请用户',
+ 'vacay.inviteHint': '邀请其他 TREK 用户共享合并的假期日历。',
+ 'vacay.selectUser': '选择用户',
+ 'vacay.sendInvite': '发送邀请',
+ 'vacay.inviteSent': '邀请已发送',
+ 'vacay.inviteError': '无法发送邀请',
+ 'vacay.pending': '待处理',
+ 'vacay.noUsersAvailable': '没有可用用户',
+ 'vacay.accept': '接受',
+ 'vacay.decline': '拒绝',
+ 'vacay.acceptFusion': '接受并合并',
+ 'vacay.inviteTitle': '合并请求',
+ 'vacay.inviteWantsToFuse': '想要与你共享假期日历。',
+ 'vacay.fuseInfo1': '你们双方将在一个共享日历中看到所有假期记录。',
+ 'vacay.fuseInfo2': '双方都可以为对方创建和编辑记录。',
+ 'vacay.fuseInfo3': '双方都可以删除记录和修改年假额度。',
+ 'vacay.fuseInfo4': '公共假日和公司假日等设置将共享。',
+ 'vacay.fuseInfo5': '任何一方都可以随时解除合并。你的记录将被保留。',
+ 'nav.myTrips': '我的旅行',
+
+ // Atlas addon
+ 'atlas.subtitle': '你的全球旅行足迹',
+ 'atlas.countries': '国家',
+ 'atlas.trips': '旅行',
+ 'atlas.places': '地点',
+ 'atlas.days': '天',
+ 'atlas.visitedCountries': '已访问国家',
+ 'atlas.cities': '城市',
+ 'atlas.noData': '暂无旅行数据',
+ 'atlas.noDataHint': '创建旅行并添加地点以查看世界地图',
+ 'atlas.lastTrip': '上次旅行',
+ 'atlas.nextTrip': '下次旅行',
+ 'atlas.daysLeft': '天后出发',
+ 'atlas.streak': '连续',
+ 'atlas.year': '年',
+ 'atlas.years': '年',
+ 'atlas.yearInRow': '年连续',
+ 'atlas.yearsInRow': '年连续',
+ 'atlas.tripIn': '次旅行在',
+ 'atlas.tripsIn': '次旅行在',
+ 'atlas.since': '自',
+ 'atlas.europe': '欧洲',
+ 'atlas.asia': '亚洲',
+ 'atlas.northAmerica': '北美洲',
+ 'atlas.southAmerica': '南美洲',
+ 'atlas.africa': '非洲',
+ 'atlas.oceania': '大洋洲',
+ 'atlas.other': '其他',
+ 'atlas.firstVisit': '首次旅行',
+ 'atlas.lastVisitLabel': '最近旅行',
+ 'atlas.tripSingular': '次旅行',
+ 'atlas.tripPlural': '次旅行',
+ 'atlas.placeVisited': '个地点已访问',
+ 'atlas.placesVisited': '个地点已访问',
+
+ // Trip Planner
+ 'trip.tabs.plan': '计划',
+ 'trip.tabs.reservations': '预订',
+ 'trip.tabs.reservationsShort': '预订',
+ 'trip.tabs.packing': '行李清单',
+ 'trip.tabs.packingShort': '行李',
+ 'trip.tabs.budget': '预算',
+ 'trip.tabs.files': '文件',
+ 'trip.loading': '加载旅行中...',
+ 'trip.mobilePlan': '计划',
+ 'trip.mobilePlaces': '地点',
+ 'trip.toast.placeUpdated': '地点已更新',
+ 'trip.toast.placeAdded': '地点已添加',
+ 'trip.toast.placeDeleted': '地点已删除',
+ 'trip.toast.selectDay': '请先选择一天',
+ 'trip.toast.assignedToDay': '地点已分配到当天',
+ 'trip.toast.reorderError': '排序失败',
+ 'trip.toast.reservationUpdated': '预订已更新',
+ 'trip.toast.reservationAdded': '预订已添加',
+ 'trip.toast.deleted': '已删除',
+ 'trip.confirm.deletePlace': '确定要删除这个地点吗?',
+
+ // Day Plan Sidebar
+ 'dayplan.emptyDay': '当天暂无计划',
+ 'dayplan.addNote': '添加备注',
+ 'dayplan.editNote': '编辑备注',
+ 'dayplan.noteAdd': '添加备注',
+ 'dayplan.noteEdit': '编辑备注',
+ 'dayplan.noteTitle': '备注',
+ 'dayplan.noteSubtitle': '每日备注',
+ 'dayplan.totalCost': '总费用',
+ 'dayplan.days': '天',
+ 'dayplan.dayN': '第 {n} 天',
+ 'dayplan.calculating': '计算中...',
+ 'dayplan.route': '路线',
+ 'dayplan.optimize': '优化',
+ 'dayplan.optimized': '路线已优化',
+ 'dayplan.routeError': '路线计算失败',
+ 'dayplan.toast.needTwoPlaces': '路线优化至少需要两个地点',
+ 'dayplan.toast.routeOptimized': '路线已优化',
+ 'dayplan.toast.noGeoPlaces': '未找到有坐标的地点用于路线计算',
+ 'dayplan.confirmed': '已确认',
+ 'dayplan.pendingRes': '待确认',
+ 'dayplan.pdf': 'PDF',
+ 'dayplan.pdfTooltip': '导出当天计划为 PDF',
+ 'dayplan.pdfError': 'PDF 导出失败',
+
+ // Places Sidebar
+ 'places.addPlace': '添加地点/活动',
+ 'places.assignToDay': '添加到哪一天?',
+ 'places.all': '全部',
+ 'places.unplanned': '未规划',
+ 'places.search': '搜索地点...',
+ 'places.allCategories': '所有分类',
+ 'places.count': '{count} 个地点',
+ 'places.countSingular': '1 个地点',
+ 'places.allPlanned': '所有地点已规划',
+ 'places.noneFound': '未找到地点',
+ 'places.editPlace': '编辑地点',
+ 'places.formName': '名称',
+ 'places.formNamePlaceholder': '如:埃菲尔铁塔',
+ 'places.formDescription': '描述',
+ 'places.formDescriptionPlaceholder': '简短描述...',
+ 'places.formAddress': '地址',
+ 'places.formAddressPlaceholder': '街道、城市、国家',
+ 'places.formLat': '纬度(如 48.8566)',
+ 'places.formLng': '经度(如 2.3522)',
+ 'places.formCategory': '分类',
+ 'places.noCategory': '无分类',
+ 'places.categoryNamePlaceholder': '分类名称',
+ 'places.formTime': '时间',
+ 'places.startTime': '开始',
+ 'places.endTime': '结束',
+ 'places.endTimeBeforeStart': '结束时间早于开始时间',
+ 'places.timeCollision': '时间冲突:',
+ 'places.formWebsite': '网站',
+ 'places.formNotesPlaceholder': '个人备注...',
+ 'places.formReservation': '预订',
+ 'places.reservationNotesPlaceholder': '预订备注、确认号...',
+ 'places.mapsSearchPlaceholder': '搜索地点...',
+ 'places.mapsSearchError': '地点搜索失败。',
+ 'places.osmHint': '使用 OpenStreetMap 搜索(无照片、营业时间或评分)。在设置中添加 Google API 密钥以获取完整信息。',
+ 'places.osmActive': '通过 OpenStreetMap 搜索(无照片、评分或营业时间)。在设置中添加 Google API 密钥以获取增强数据。',
+ 'places.categoryCreateError': '创建分类失败',
+ 'places.nameRequired': '请输入名称',
+ 'places.saveError': '保存失败',
+ // Place Inspector
+ 'inspector.opened': '营业中',
+ 'inspector.closed': '已关闭',
+ 'inspector.openingHours': '营业时间',
+ 'inspector.showHours': '显示营业时间',
+ 'inspector.files': '文件',
+ 'inspector.filesCount': '{count} 个文件',
+ 'inspector.removeFromDay': '从当天移除',
+ 'inspector.addToDay': '添加到当天',
+ 'inspector.confirmedRes': '已确认预订',
+ 'inspector.pendingRes': '待确认预订',
+ 'inspector.google': '在 Google Maps 中打开',
+ 'inspector.website': '打开网站',
+ 'inspector.addRes': '预订',
+ 'inspector.editRes': '编辑预订',
+ 'inspector.participants': '参与者',
+
+ // Reservations
+ 'reservations.title': '预订',
+ 'reservations.empty': '暂无预订',
+ 'reservations.emptyHint': '添加航班、酒店等预订信息',
+ 'reservations.add': '添加预订',
+ 'reservations.addManual': '手动添加',
+ 'reservations.placeHint': '提示:建议从地点直接创建预订,以便与日程计划关联。',
+ 'reservations.confirmed': '已确认',
+ 'reservations.pending': '待确认',
+ 'reservations.summary': '{confirmed} 已确认,{pending} 待确认',
+ 'reservations.fromPlan': '来自计划',
+ 'reservations.showFiles': '查看文件',
+ 'reservations.editTitle': '编辑预订',
+ 'reservations.status': '状态',
+ 'reservations.datetime': '日期和时间',
+ 'reservations.startTime': '开始时间',
+ 'reservations.endTime': '结束时间',
+ 'reservations.date': '日期',
+ 'reservations.time': '时间',
+ 'reservations.timeAlt': '时间(备选,如 19:30)',
+ 'reservations.notes': '备注',
+ 'reservations.notesPlaceholder': '其他备注...',
+ 'reservations.meta.airline': '航空公司',
+ 'reservations.meta.flightNumber': '航班号',
+ 'reservations.meta.from': '出发',
+ 'reservations.meta.to': '到达',
+ 'reservations.meta.trainNumber': '车次',
+ 'reservations.meta.platform': '站台',
+ 'reservations.meta.seat': '座位',
+ 'reservations.meta.checkIn': '入住',
+ 'reservations.meta.checkOut': '退房',
+ 'reservations.meta.linkAccommodation': '住宿',
+ 'reservations.meta.pickAccommodation': '关联住宿',
+ 'reservations.meta.noAccommodation': '无',
+ 'reservations.meta.hotelPlace': '酒店',
+ 'reservations.meta.pickHotel': '选择酒店',
+ 'reservations.meta.fromDay': '从',
+ 'reservations.meta.toDay': '到',
+ 'reservations.meta.selectDay': '选择日期',
+ 'reservations.type.flight': '航班',
+ 'reservations.type.hotel': '酒店',
+ 'reservations.type.restaurant': '餐厅',
+ 'reservations.type.train': '火车',
+ 'reservations.type.car': '租车',
+ 'reservations.type.cruise': '邮轮',
+ 'reservations.type.event': '活动',
+ 'reservations.type.tour': '旅游团',
+ 'reservations.type.other': '其他',
+ 'reservations.confirm.delete': '确定要删除预订「{name}」吗?',
+ 'reservations.toast.updated': '预订已更新',
+ 'reservations.toast.removed': '预订已删除',
+ 'reservations.toast.fileUploaded': '文件已上传',
+ 'reservations.toast.uploadError': '上传失败',
+ 'reservations.newTitle': '新建预订',
+ 'reservations.bookingType': '预订类型',
+ 'reservations.titleLabel': '标题',
+ 'reservations.titlePlaceholder': '如:汉莎 LH123、阿德隆酒店...',
+ 'reservations.locationAddress': '地点 / 地址',
+ 'reservations.locationPlaceholder': '地址、机场、酒店...',
+ 'reservations.confirmationCode': '预订码',
+ 'reservations.confirmationPlaceholder': '如:ABC12345',
+ 'reservations.day': '日期',
+ 'reservations.noDay': '无日期',
+ 'reservations.place': '地点',
+ 'reservations.noPlace': '无地点',
+ 'reservations.pendingSave': '将被保存…',
+ 'reservations.uploading': '上传中...',
+ 'reservations.attachFile': '附加文件',
+ 'reservations.toast.saveError': '保存失败',
+ 'reservations.toast.updateError': '更新失败',
+ 'reservations.toast.deleteError': '删除失败',
+ 'reservations.confirm.remove': '移除「{name}」的预订?',
+ 'reservations.linkAssignment': '关联日程分配',
+ 'reservations.pickAssignment': '从计划中选择一个分配...',
+ 'reservations.noAssignment': '无关联(独立)',
+
+ // Budget
+ 'budget.title': '预算',
+ 'budget.emptyTitle': '尚未创建预算',
+ 'budget.emptyText': '创建分类和条目来规划旅行预算',
+ 'budget.emptyPlaceholder': '输入分类名称...',
+ 'budget.createCategory': '创建分类',
+ 'budget.category': '分类',
+ 'budget.categoryName': '分类名称',
+ 'budget.table.name': '名称',
+ 'budget.table.total': '合计',
+ 'budget.table.persons': '人数',
+ 'budget.table.days': '天数',
+ 'budget.table.perPerson': '人均',
+ 'budget.table.perDay': '日均',
+ 'budget.table.perPersonDay': '人日均',
+ 'budget.table.note': '备注',
+ 'budget.newEntry': '新建条目',
+ 'budget.defaultEntry': '新建条目',
+ 'budget.defaultCategory': '新分类',
+ 'budget.total': '合计',
+ 'budget.totalBudget': '总预算',
+ 'budget.byCategory': '按分类',
+ 'budget.editTooltip': '点击编辑',
+ 'budget.confirm.deleteCategory': '确定删除分类「{name}」及其 {count} 个条目?',
+ 'budget.deleteCategory': '删除分类',
+ 'budget.perPerson': '人均',
+ 'budget.paid': '已支付',
+ 'budget.open': '未支付',
+ 'budget.noMembers': '未分配成员',
+
+ // Files
+ 'files.title': '文件',
+ 'files.count': '{count} 个文件',
+ 'files.countSingular': '1 个文件',
+ 'files.uploaded': '已上传 {count} 个',
+ 'files.uploadError': '上传失败',
+ 'files.dropzone': '将文件拖放到此处',
+ 'files.dropzoneHint': '或点击浏览',
+ 'files.allowedTypes': '图片、PDF、DOC、DOCX、XLS、XLSX、TXT、CSV · 最大 50 MB',
+ 'files.uploading': '上传中...',
+ 'files.filterAll': '全部',
+ 'files.filterPdf': 'PDF',
+ 'files.filterImages': '图片',
+ 'files.filterDocs': '文档',
+ 'files.filterCollab': '协作笔记',
+ 'files.sourceCollab': '来自协作笔记',
+ 'files.empty': '暂无文件',
+ 'files.emptyHint': '上传文件以附加到旅行中',
+ 'files.openTab': '在新标签页中打开',
+ 'files.confirm.delete': '确定要删除此文件吗?',
+ 'files.toast.deleted': '文件已删除',
+ 'files.toast.deleteError': '删除文件失败',
+ 'files.sourcePlan': '日程计划',
+ 'files.sourceBooking': '预订',
+ 'files.attach': '附加',
+ 'files.pasteHint': '也可以从剪贴板粘贴图片 (Ctrl+V)',
+ 'files.trash': '回收站',
+ 'files.trashEmpty': '回收站为空',
+ 'files.emptyTrash': '清空回收站',
+ 'files.restore': '恢复',
+ 'files.star': '收藏',
+ 'files.unstar': '取消收藏',
+ 'files.assign': '分配',
+ 'files.assignTitle': '分配文件',
+ 'files.assignPlace': '地点',
+ 'files.assignBooking': '预订',
+ 'files.unassigned': '未分配',
+ 'files.unlink': '移除关联',
+ 'files.toast.trashed': '已移至回收站',
+ 'files.toast.restored': '文件已恢复',
+ 'files.toast.trashEmptied': '回收站已清空',
+ 'files.toast.assigned': '文件已分配',
+ 'files.toast.assignError': '分配失败',
+ 'files.toast.restoreError': '恢复失败',
+ 'files.confirm.permanentDelete': '永久删除此文件?此操作无法撤销。',
+ 'files.confirm.emptyTrash': '永久删除回收站中的所有文件?此操作无法撤销。',
+ 'files.noteLabel': '备注',
+ 'files.notePlaceholder': '添加备注...',
+
+ // Packing
+ 'packing.title': '行李清单',
+ 'packing.empty': '行李清单为空',
+ 'packing.progress': '已打包 {packed}/{total}({percent}%)',
+ 'packing.clearChecked': '移除 {count} 个已勾选',
+ 'packing.clearCheckedShort': '移除 {count} 个',
+ 'packing.suggestions': '建议',
+ 'packing.suggestionsTitle': '添加建议',
+ 'packing.allSuggested': '所有建议已添加',
+ 'packing.allPacked': '全部打包完成!',
+ 'packing.addPlaceholder': '添加新物品...',
+ 'packing.categoryPlaceholder': '分类...',
+ 'packing.filterAll': '全部',
+ 'packing.filterOpen': '未完成',
+ 'packing.filterDone': '已完成',
+ 'packing.emptyTitle': '行李清单为空',
+ 'packing.emptyHint': '添加物品或使用建议',
+ 'packing.emptyFiltered': '没有匹配的物品',
+ 'packing.menuRename': '重命名',
+ 'packing.menuCheckAll': '全部勾选',
+ 'packing.menuUncheckAll': '取消全部勾选',
+ 'packing.menuDeleteCat': '删除分类',
+ 'packing.changeCategory': '更改分类',
+ 'packing.confirm.clearChecked': '确定移除 {count} 个已勾选的物品?',
+ 'packing.confirm.deleteCat': '确定删除分类「{name}」及其 {count} 个物品?',
+ 'packing.defaultCategory': '其他',
+ 'packing.toast.saveError': '保存失败',
+ 'packing.toast.deleteError': '删除失败',
+ 'packing.toast.renameError': '重命名失败',
+ 'packing.toast.addError': '添加失败',
+
+ // Packing suggestions
+ 'packing.suggestions.items': [
+ { name: '护照', category: '证件' },
+ { name: '身份证', category: '证件' },
+ { name: '旅行保险', category: '证件' },
+ { name: '机票', category: '证件' },
+ { name: '信用卡', category: '财务' },
+ { name: '现金', category: '财务' },
+ { name: '签证', category: '证件' },
+ { name: 'T恤', category: '衣物' },
+ { name: '裤子', category: '衣物' },
+ { name: '内衣', category: '衣物' },
+ { name: '袜子', category: '衣物' },
+ { name: '外套', category: '衣物' },
+ { name: '睡衣', category: '衣物' },
+ { name: '泳衣', category: '衣物' },
+ { name: '雨衣', category: '衣物' },
+ { name: '舒适的鞋子', category: '衣物' },
+ { name: '牙刷', category: '洗漱用品' },
+ { name: '牙膏', category: '洗漱用品' },
+ { name: '洗发水', category: '洗漱用品' },
+ { name: '除臭剂', category: '洗漱用品' },
+ { name: '防晒霜', category: '洗漱用品' },
+ { name: '剃须刀', category: '洗漱用品' },
+ { name: '充电器', category: '电子产品' },
+ { name: '充电宝', category: '电子产品' },
+ { name: '耳机', category: '电子产品' },
+ { name: '旅行转换插头', category: '电子产品' },
+ { name: '相机', category: '电子产品' },
+ { name: '止痛药', category: '健康' },
+ { name: '创可贴', category: '健康' },
+ { name: '消毒液', category: '健康' },
+ ],
+
+ // Members / Sharing
+ 'members.shareTrip': '分享旅行',
+ 'members.inviteUser': '邀请用户',
+ 'members.selectUser': '选择用户…',
+ 'members.invite': '邀请',
+ 'members.allHaveAccess': '所有用户均已拥有访问权限。',
+ 'members.access': '访问权限',
+ 'members.person': '人',
+ 'members.persons': '人',
+ 'members.you': '你',
+ 'members.owner': '所有者',
+ 'members.leaveTrip': '退出旅行',
+ 'members.removeAccess': '移除访问权限',
+ 'members.confirmLeave': '退出旅行?你将失去访问权限。',
+ 'members.confirmRemove': '移除该用户的访问权限?',
+ 'members.loadError': '加载成员失败',
+ 'members.added': '已添加',
+ 'members.addError': '添加失败',
+ 'members.removed': '成员已移除',
+ 'members.removeError': '移除失败',
+
+ // Categories (Admin)
+ 'categories.title': '分类',
+ 'categories.subtitle': '管理地点分类',
+ 'categories.new': '新建分类',
+ 'categories.empty': '暂无分类',
+ 'categories.namePlaceholder': '分类名称',
+ 'categories.icon': '图标',
+ 'categories.color': '颜色',
+ 'categories.customColor': '选择自定义颜色',
+ 'categories.preview': '预览',
+ 'categories.defaultName': '分类',
+ 'categories.update': '更新',
+ 'categories.create': '创建',
+ 'categories.confirm.delete': '删除分类?该分类下的地点不会被删除。',
+ 'categories.toast.loadError': '加载分类失败',
+ 'categories.toast.nameRequired': '请输入名称',
+ 'categories.toast.updated': '分类已更新',
+ 'categories.toast.created': '分类已创建',
+ 'categories.toast.saveError': '保存失败',
+ 'categories.toast.deleted': '分类已删除',
+ 'categories.toast.deleteError': '删除失败',
+
+ // Backup (Admin)
+ 'backup.title': '数据备份',
+ 'backup.subtitle': '数据库和所有上传文件',
+ 'backup.refresh': '刷新',
+ 'backup.upload': '上传备份',
+ 'backup.uploading': '上传中…',
+ 'backup.create': '创建备份',
+ 'backup.creating': '创建中…',
+ 'backup.empty': '暂无备份',
+ 'backup.createFirst': '创建第一个备份',
+ 'backup.download': '下载',
+ 'backup.restore': '恢复',
+ 'backup.confirm.restore': '恢复备份「{name}」?\n\n所有当前数据将被备份数据替换。',
+ 'backup.confirm.uploadRestore': '上传并恢复备份文件「{name}」?\n\n所有当前数据将被覆盖。',
+ 'backup.confirm.delete': '删除备份「{name}」?',
+ 'backup.toast.loadError': '加载备份失败',
+ 'backup.toast.created': '备份创建成功',
+ 'backup.toast.createError': '创建备份失败',
+ 'backup.toast.restored': '备份已恢复。页面即将刷新…',
+ 'backup.toast.restoreError': '恢复失败',
+ 'backup.toast.uploadError': '上传失败',
+ 'backup.toast.deleted': '备份已删除',
+ 'backup.toast.deleteError': '删除失败',
+ 'backup.toast.downloadError': '下载失败',
+ 'backup.toast.settingsSaved': '自动备份设置已保存',
+ 'backup.toast.settingsError': '保存设置失败',
+ 'backup.auto.title': '自动备份',
+ 'backup.auto.subtitle': '按计划自动备份',
+ 'backup.auto.enable': '启用自动备份',
+ 'backup.auto.enableHint': '将按所选计划自动创建备份',
+ 'backup.auto.interval': '间隔',
+ 'backup.auto.keepLabel': '自动删除旧备份',
+ 'backup.interval.hourly': '每小时',
+ 'backup.interval.daily': '每天',
+ 'backup.interval.weekly': '每周',
+ 'backup.interval.monthly': '每月',
+ 'backup.keep.1day': '1 天',
+ 'backup.keep.3days': '3 天',
+ 'backup.keep.7days': '7 天',
+ 'backup.keep.14days': '14 天',
+ 'backup.keep.30days': '30 天',
+ 'backup.keep.forever': '永久保留',
+
+ // Photos
+ 'photos.allDays': '所有天',
+ 'photos.noPhotos': '暂无照片',
+ 'photos.uploadHint': '上传你的旅行照片',
+ 'photos.clickToSelect': '或点击选择',
+ 'photos.linkPlace': '关联地点',
+ 'photos.noPlace': '无地点',
+ 'photos.uploadN': '上传 {n} 张照片',
+
+ // Backup restore modal
+ 'backup.restoreConfirmTitle': '恢复备份?',
+ 'backup.restoreWarning': '所有当前数据(旅行、地点、用户、上传文件)将被备份数据永久替换。此操作无法撤销。',
+ 'backup.restoreTip': '提示:恢复前建议先备份当前状态。',
+ 'backup.restoreConfirm': '确认恢复',
+
+ // PDF
+ 'pdf.travelPlan': '旅行计划',
+ 'pdf.planned': '已规划',
+ 'pdf.costLabel': '费用 EUR',
+ 'pdf.preview': 'PDF 预览',
+ 'pdf.saveAsPdf': '保存为 PDF',
+
+ // Planner
+ 'planner.places': '地点',
+ 'planner.bookings': '预订',
+ 'planner.packingList': '行李清单',
+ 'planner.documents': '文档',
+ 'planner.dayPlan': '日程计划',
+ 'planner.reservations': '预订',
+ 'planner.minTwoPlaces': '至少需要 2 个有坐标的地点',
+ 'planner.noGeoPlaces': '没有有坐标的地点',
+ 'planner.routeCalculated': '路线已计算',
+ 'planner.routeCalcFailed': '无法计算路线',
+ 'planner.routeError': '路线计算错误',
+ 'planner.routeOptimized': '路线已优化',
+ 'planner.reservationUpdated': '预订已更新',
+ 'planner.reservationAdded': '预订已添加',
+ 'planner.confirmDeleteReservation': '删除预订?',
+ 'planner.reservationDeleted': '预订已删除',
+ 'planner.days': '天',
+ 'planner.allPlaces': '所有地点',
+ 'planner.totalPlaces': '共 {n} 个地点',
+ 'planner.noDaysPlanned': '尚未规划天数',
+ 'planner.editTrip': '编辑旅行 \u2192',
+ 'planner.placeOne': '1 个地点',
+ 'planner.placeN': '{n} 个地点',
+ 'planner.addNote': '添加备注',
+ 'planner.noEntries': '当天无条目',
+ 'planner.addPlace': '添加地点/活动',
+ 'planner.addPlaceShort': '+ 添加地点/活动',
+ 'planner.resPending': '预订待确认 · ',
+ 'planner.resConfirmed': '预订已确认 · ',
+ 'planner.notePlaceholder': '备注…',
+ 'planner.noteTimePlaceholder': '时间(可选)',
+ 'planner.noteExamplePlaceholder': '如:14:30 从中央车站乘 S3,7 号码头渡轮,午餐休息…',
+ 'planner.totalCost': '总费用',
+ 'planner.searchPlaces': '搜索地点…',
+ 'planner.allCategories': '所有分类',
+ 'planner.noPlacesFound': '未找到地点',
+ 'planner.addFirstPlace': '添加第一个地点',
+ 'planner.noReservations': '暂无预订',
+ 'planner.addFirstReservation': '添加第一个预订',
+ 'planner.new': '新建',
+ 'planner.addToDay': '+ 天',
+ 'planner.calculating': '计算中…',
+ 'planner.route': '路线',
+ 'planner.optimize': '优化',
+ 'planner.openGoogleMaps': '在 Google Maps 中打开',
+ 'planner.selectDayHint': '从左侧列表选择一天以查看日程计划',
+ 'planner.noPlacesForDay': '当天暂无地点',
+ 'planner.addPlacesLink': '添加地点 \u2192',
+ 'planner.minTotal': '分钟 合计',
+ 'planner.noReservation': '无预订',
+ 'planner.removeFromDay': '从当天移除',
+ 'planner.addToThisDay': '添加到当天',
+ 'planner.overview': '概览',
+ 'planner.noDays': '暂无天数',
+ 'planner.editTripToAddDays': '编辑旅行以添加天数',
+ 'planner.dayCount': '{n} 天',
+ 'planner.clickToUnlock': '点击解锁',
+ 'planner.keepPosition': '路线优化时保持位置',
+ 'planner.dayDetails': '日程详情',
+ 'planner.dayN': '第 {n} 天',
+
+ // Dashboard Stats
+ 'stats.countries': '国家',
+ 'stats.cities': '城市',
+ 'stats.trips': '旅行',
+ 'stats.places': '地点',
+ 'stats.worldProgress': '全球进度',
+ 'stats.visited': '已访问',
+ 'stats.remaining': '未访问',
+ 'stats.visitedCountries': '已访问国家',
+
+ // Day Detail Panel
+ 'day.precipProb': '降水概率',
+ 'day.precipitation': '降水量',
+ 'day.wind': '风速',
+ 'day.sunrise': '日出',
+ 'day.sunset': '日落',
+ 'day.hourlyForecast': '逐小时预报',
+ 'day.climateHint': '历史平均值——实际预报在该日期前 16 天内可用。',
+ 'day.noWeather': '无天气数据。请添加有坐标的地点。',
+ 'day.overview': '每日概览',
+ 'day.accommodation': '住宿',
+ 'day.addAccommodation': '添加住宿',
+ 'day.hotelDayRange': '应用到天数',
+ 'day.noPlacesForHotel': '请先在旅行中添加地点',
+ 'day.allDays': '全部',
+ 'day.checkIn': '入住',
+ 'day.checkOut': '退房',
+ 'day.confirmation': '确认号',
+ 'day.editAccommodation': '编辑住宿',
+ 'day.reservations': '预订',
+
+ // Collab Addon
+ 'collab.tabs.chat': '聊天',
+ 'collab.tabs.notes': '笔记',
+ 'collab.tabs.polls': '投票',
+ 'collab.whatsNext.title': '接下来',
+ 'collab.whatsNext.today': '今天',
+ 'collab.whatsNext.tomorrow': '明天',
+ 'collab.whatsNext.empty': '暂无活动',
+ 'collab.whatsNext.until': '至',
+ 'collab.whatsNext.emptyHint': '有时间安排的活动将显示在此',
+ 'collab.chat.send': '发送',
+ 'collab.chat.placeholder': '输入消息...',
+ 'collab.chat.empty': '开始对话',
+ 'collab.chat.emptyHint': '消息对所有旅行成员可见',
+ 'collab.chat.emptyDesc': '与旅伴分享想法、计划和动态',
+ 'collab.chat.today': '今天',
+ 'collab.chat.yesterday': '昨天',
+ 'collab.chat.deletedMessage': '删除了一条消息',
+ 'collab.chat.loadMore': '加载更早的消息',
+ 'collab.chat.justNow': '刚刚',
+ 'collab.chat.minutesAgo': '{n} 分钟前',
+ 'collab.chat.hoursAgo': '{n} 小时前',
+ 'collab.notes.title': '笔记',
+ 'collab.notes.new': '新建笔记',
+ 'collab.notes.empty': '暂无笔记',
+ 'collab.notes.emptyHint': '开始记录想法和计划',
+ 'collab.notes.all': '全部',
+ 'collab.notes.titlePlaceholder': '笔记标题',
+ 'collab.notes.contentPlaceholder': '写点什么...',
+ 'collab.notes.categoryPlaceholder': '分类',
+ 'collab.notes.newCategory': '新建分类...',
+ 'collab.notes.category': '分类',
+ 'collab.notes.noCategory': '无分类',
+ 'collab.notes.color': '颜色',
+ 'collab.notes.save': '保存',
+ 'collab.notes.cancel': '取消',
+ 'collab.notes.edit': '编辑',
+ 'collab.notes.delete': '删除',
+ 'collab.notes.pin': '置顶',
+ 'collab.notes.unpin': '取消置顶',
+ 'collab.notes.daysAgo': '{n} 天前',
+ 'collab.notes.categorySettings': '管理分类',
+ 'collab.notes.create': '创建',
+ 'collab.notes.website': '网站',
+ 'collab.notes.websitePlaceholder': 'https://...',
+ 'collab.notes.attachFiles': '附加文件',
+ 'collab.notes.noCategoriesYet': '暂无分类',
+ 'collab.notes.emptyDesc': '创建一个笔记开始吧',
+ 'collab.polls.title': '投票',
+ 'collab.polls.new': '新建投票',
+ 'collab.polls.empty': '暂无投票',
+ 'collab.polls.emptyHint': '向团队提问并一起投票',
+ 'collab.polls.question': '问题',
+ 'collab.polls.questionPlaceholder': '我们应该做什么?',
+ 'collab.polls.addOption': '+ 添加选项',
+ 'collab.polls.optionPlaceholder': '选项 {n}',
+ 'collab.polls.create': '创建投票',
+ 'collab.polls.close': '关闭',
+ 'collab.polls.closed': '已关闭',
+ 'collab.polls.votes': '{n} 票',
+ 'collab.polls.vote': '{n} 票',
+ 'collab.polls.multipleChoice': '多选',
+ 'collab.polls.multiChoice': '多选',
+ 'collab.polls.deadline': '截止时间',
+ 'collab.polls.option': '选项',
+ 'collab.polls.options': '选项',
+ 'collab.polls.delete': '删除',
+ 'collab.polls.closedSection': '已关闭',
+}
+
+export default zh
diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx
index 8b0d3d2..90d0250 100644
--- a/client/src/pages/SettingsPage.tsx
+++ b/client/src/pages/SettingsPage.tsx
@@ -272,11 +272,15 @@ export default function SettingsPage(): React.ReactElement {
{/* Sprache */}
-
+
{[
{ value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'English' },
{ value: 'es', label: 'Español' },
+ { value: 'fr', label: 'Français' },
+ { value: 'nl', label: 'Nederlands' },
+ { value: 'ru', label: 'Русский' },
+ { value: 'zh', label: '中文' },
].map(opt => (
{t('vacay.legend')}
- {plan?.holidays_enabled && }
+ {plan?.holidays_enabled && (plan?.holiday_calendars ?? []).length === 0 && (
+
+ )}
+ {plan?.holidays_enabled && (plan?.holiday_calendars ?? []).map(cal => (
+
+ ))}
{plan?.company_holidays_enabled && }
{plan?.block_weekends && }
diff --git a/client/src/store/vacayStore.ts b/client/src/store/vacayStore.ts
index c58e3a3..f1d7ef7 100644
--- a/client/src/store/vacayStore.ts
+++ b/client/src/store/vacayStore.ts
@@ -1,7 +1,7 @@
import { create } from 'zustand'
import apiClient from '../api/client'
import type { AxiosResponse } from 'axios'
-import type { VacayPlan, VacayUser, VacayEntry, VacayStat, HolidaysMap, HolidayInfo } from '../types'
+import type { VacayPlan, VacayUser, VacayEntry, VacayStat, HolidaysMap, HolidayInfo, VacayHolidayCalendar } from '../types'
const ax = apiClient
@@ -65,6 +65,9 @@ interface VacayApi {
updateStats: (year: number, days: number, targetUserId?: number) => Promise
getCountries: () => Promise<{ countries: string[] }>
getHolidays: (year: number, country: string) => Promise
+ addHolidayCalendar: (data: { region: string; color?: string; label?: string | null }) => Promise<{ calendar: VacayHolidayCalendar }>
+ updateHolidayCalendar: (id: number, data: { region?: string; color?: string; label?: string | null }) => Promise<{ calendar: VacayHolidayCalendar }>
+ deleteHolidayCalendar: (id: number) => Promise
}
const api: VacayApi = {
@@ -87,6 +90,9 @@ const api: VacayApi = {
updateStats: (year, days, targetUserId) => ax.put(`/addons/vacay/stats/${year}`, { vacation_days: days, target_user_id: targetUserId }).then((r: AxiosResponse) => r.data),
getCountries: () => ax.get('/addons/vacay/holidays/countries').then((r: AxiosResponse) => r.data),
getHolidays: (year, country) => ax.get(`/addons/vacay/holidays/${year}/${country}`).then((r: AxiosResponse) => r.data),
+ addHolidayCalendar: (data) => ax.post('/addons/vacay/plan/holiday-calendars', data).then((r: AxiosResponse) => r.data),
+ updateHolidayCalendar: (id, data) => ax.put(`/addons/vacay/plan/holiday-calendars/${id}`, data).then((r: AxiosResponse) => r.data),
+ deleteHolidayCalendar: (id) => ax.delete(`/addons/vacay/plan/holiday-calendars/${id}`).then((r: AxiosResponse) => r.data),
}
interface VacayState {
@@ -124,6 +130,9 @@ interface VacayState {
loadStats: (year?: number) => Promise
updateVacationDays: (year: number, days: number, targetUserId?: number) => Promise
loadHolidays: (year?: number) => Promise
+ addHolidayCalendar: (data: { region: string; color?: string; label?: string | null }) => Promise
+ updateHolidayCalendar: (id: number, data: { region?: string; color?: string; label?: string | null }) => Promise
+ deleteHolidayCalendar: (id: number) => Promise
loadAll: () => Promise
}
@@ -247,29 +256,47 @@ export const useVacayStore = create((set, get) => ({
loadHolidays: async (year?: number) => {
const y = year || get().selectedYear
const plan = get().plan
- if (!plan?.holidays_enabled || !plan?.holidays_region) {
+ const calendars = plan?.holiday_calendars ?? []
+ if (!plan?.holidays_enabled || calendars.length === 0) {
set({ holidays: {} })
return
}
- const country = plan.holidays_region.split('-')[0]
- const region = plan.holidays_region.includes('-') ? plan.holidays_region : null
- try {
- const data = await api.getHolidays(y, country)
- const hasRegions = data.some((h: VacayHolidayRaw) => h.counties && h.counties.length > 0)
- if (hasRegions && !region) {
- set({ holidays: {} })
- return
- }
- const map: HolidaysMap = {}
- data.forEach((h: VacayHolidayRaw) => {
- if (h.global || !h.counties || (region && h.counties.includes(region))) {
- map[h.date] = { name: h.name, localName: h.localName }
- }
- })
- set({ holidays: map })
- } catch {
- set({ holidays: {} })
+ const map: HolidaysMap = {}
+ for (const cal of calendars) {
+ const country = cal.region.split('-')[0]
+ const region = cal.region.includes('-') ? cal.region : null
+ try {
+ const data = await api.getHolidays(y, country)
+ const hasRegions = data.some((h: VacayHolidayRaw) => h.counties && h.counties.length > 0)
+ if (hasRegions && !region) continue
+ data.forEach((h: VacayHolidayRaw) => {
+ if (h.global || !h.counties || (region && h.counties.includes(region))) {
+ if (!map[h.date]) {
+ map[h.date] = { name: h.name, localName: h.localName, color: cal.color, label: cal.label }
+ }
+ }
+ })
+ } catch { /* API error, skip */ }
}
+ set({ holidays: map })
+ },
+
+ addHolidayCalendar: async (data) => {
+ await api.addHolidayCalendar(data)
+ await get().loadPlan()
+ await get().loadHolidays()
+ },
+
+ updateHolidayCalendar: async (id, data) => {
+ await api.updateHolidayCalendar(id, data)
+ await get().loadPlan()
+ await get().loadHolidays()
+ },
+
+ deleteHolidayCalendar: async (id) => {
+ await api.deleteHolidayCalendar(id)
+ await get().loadPlan()
+ await get().loadHolidays()
},
loadAll: async () => {
diff --git a/client/src/types.ts b/client/src/types.ts
index d434f0f..7bc49dd 100644
--- a/client/src/types.ts
+++ b/client/src/types.ts
@@ -283,10 +283,23 @@ export interface WebSocketEvent {
}
// Vacay types
+export interface VacayHolidayCalendar {
+ id: number
+ plan_id: number
+ region: string
+ label: string | null
+ color: string
+ sort_order: number
+}
+
export interface VacayPlan {
id: number
holidays_enabled: boolean
holidays_region: string | null
+ holiday_calendars: VacayHolidayCalendar[]
+ block_weekends: boolean
+ carry_over_enabled: boolean
+ company_holidays_enabled: boolean
name?: string
year?: number
owner_id?: number
@@ -303,6 +316,9 @@ export interface VacayUser {
export interface VacayEntry {
date: string
user_id: number
+ plan_id?: number
+ person_color?: string
+ person_name?: string
}
export interface VacayStat {
@@ -314,6 +330,8 @@ export interface VacayStat {
export interface HolidayInfo {
name: string
localName: string
+ color: string
+ label: string | null
}
export interface HolidaysMap {
diff --git a/server/package-lock.json b/server/package-lock.json
index bbd2eca..d8e2f28 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -16,7 +16,7 @@
"express": "^4.18.3",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
- "multer": "^1.4.5-lts.1",
+ "multer": "^2.1.1",
"node-cron": "^4.2.1",
"node-fetch": "^2.7.0",
"otplib": "^12.0.1",
@@ -1270,50 +1270,20 @@
}
},
"node_modules/concat-stream": {
- "version": "1.6.2",
- "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
- "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
+ "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
- "node >= 0.8"
+ "node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
- "readable-stream": "^2.2.2",
+ "readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
- "node_modules/concat-stream/node_modules/readable-stream": {
- "version": "2.3.8",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
- "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
- "license": "MIT",
- "dependencies": {
- "core-util-is": "~1.0.0",
- "inherits": "~2.0.3",
- "isarray": "~1.0.0",
- "process-nextick-args": "~2.0.0",
- "safe-buffer": "~5.1.1",
- "string_decoder": "~1.1.1",
- "util-deprecate": "~1.0.1"
- }
- },
- "node_modules/concat-stream/node_modules/safe-buffer": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
- "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
- "license": "MIT"
- },
- "node_modules/concat-stream/node_modules/string_decoder": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
- "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
- "license": "MIT",
- "dependencies": {
- "safe-buffer": "~5.1.0"
- }
- },
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -2457,18 +2427,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/mkdirp": {
- "version": "0.5.6",
- "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
- "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
- "license": "MIT",
- "dependencies": {
- "minimist": "^1.2.6"
- },
- "bin": {
- "mkdirp": "bin/cmd.js"
- }
- },
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
@@ -2482,22 +2440,22 @@
"license": "MIT"
},
"node_modules/multer": {
- "version": "1.4.5-lts.2",
- "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
- "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
- "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
+ "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
- "busboy": "^1.0.0",
- "concat-stream": "^1.5.2",
- "mkdirp": "^0.5.4",
- "object-assign": "^4.1.1",
- "type-is": "^1.6.4",
- "xtend": "^4.0.0"
+ "busboy": "^1.6.0",
+ "concat-stream": "^2.0.0",
+ "type-is": "^1.6.18"
},
"engines": {
- "node": ">= 6.0.0"
+ "node": ">= 10.16.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
}
},
"node_modules/napi-build-utils": {
@@ -3603,56 +3561,6 @@
}
}
},
- "node_modules/xtend": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
- "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
- "license": "MIT",
- "engines": {
- "node": ">=0.4"
- }
- },
- "node_modules/y18n": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
- "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
- "license": "ISC"
- },
- "node_modules/yargs": {
- "version": "15.4.1",
- "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
- "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
- "license": "MIT",
- "dependencies": {
- "cliui": "^6.0.0",
- "decamelize": "^1.2.0",
- "find-up": "^4.1.0",
- "get-caller-file": "^2.0.1",
- "require-directory": "^2.1.1",
- "require-main-filename": "^2.0.0",
- "set-blocking": "^2.0.0",
- "string-width": "^4.2.0",
- "which-module": "^2.0.0",
- "y18n": "^4.0.0",
- "yargs-parser": "^18.1.2"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/yargs-parser": {
- "version": "18.1.3",
- "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
- "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
- "license": "ISC",
- "dependencies": {
- "camelcase": "^5.0.0",
- "decamelize": "^1.2.0"
- },
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/zip-stream": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-5.0.2.tgz",
diff --git a/server/package.json b/server/package.json
index 453f80d..fdd7b37 100644
--- a/server/package.json
+++ b/server/package.json
@@ -15,7 +15,7 @@
"express": "^4.18.3",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
- "multer": "^1.4.5-lts.1",
+ "multer": "^2.1.1",
"node-cron": "^4.2.1",
"otplib": "^12.0.1",
"qrcode": "^1.5.4",
diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts
index 60b1f79..425a914 100644
--- a/server/src/db/schema.ts
+++ b/server/src/db/schema.ts
@@ -283,6 +283,15 @@ function createTables(db: Database.Database): void {
UNIQUE(plan_id, date)
);
+ CREATE TABLE IF NOT EXISTS vacay_holiday_calendars (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
+ region TEXT NOT NULL,
+ label TEXT,
+ color TEXT NOT NULL DEFAULT '#fecaca',
+ sort_order INTEGER NOT NULL DEFAULT 0
+ );
+
CREATE TABLE IF NOT EXISTS day_accommodations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
diff --git a/server/src/index.ts b/server/src/index.ts
index 74af704..db72af2 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -44,6 +44,8 @@ if (allowedOrigins) {
corsOrigin = true;
}
+const shouldForceHttps = process.env.FORCE_HTTPS === 'true';
+
app.use(cors({
origin: corsOrigin,
credentials: true
@@ -60,13 +62,15 @@ app.use(helmet({
objectSrc: ["'self'"],
frameSrc: ["'self'"],
frameAncestors: ["'self'"],
+ upgradeInsecureRequests: shouldForceHttps ? [] : null
}
},
crossOriginEmbedderPolicy: false,
- hsts: process.env.FORCE_HTTPS === 'true' ? { maxAge: 31536000, includeSubDomains: false } : false,
+ hsts: shouldForceHttps ? { maxAge: 31536000, includeSubDomains: false } : false,
}));
+
// Redirect HTTP to HTTPS (opt-in via FORCE_HTTPS=true)
-if (process.env.FORCE_HTTPS === 'true') {
+if (shouldForceHttps) {
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next();
res.redirect(301, 'https://' + req.headers.host + req.url);
diff --git a/server/src/routes/vacay.ts b/server/src/routes/vacay.ts
index 02b13d9..ba0cbe3 100644
--- a/server/src/routes/vacay.ts
+++ b/server/src/routes/vacay.ts
@@ -43,9 +43,59 @@ interface Holiday {
counties?: string[] | null;
}
+interface VacayHolidayCalendar {
+ id: number;
+ plan_id: number;
+ region: string;
+ label: string | null;
+ color: string;
+ sort_order: number;
+}
+
const holidayCache = new Map();
const CACHE_TTL = 24 * 60 * 60 * 1000;
+async function applyHolidayCalendars(planId: number): Promise {
+ const plan = db.prepare('SELECT holidays_enabled FROM vacay_plans WHERE id = ?').get(planId) as { holidays_enabled: number } | undefined;
+ if (!plan?.holidays_enabled) return;
+ const calendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(planId) as VacayHolidayCalendar[];
+ if (calendars.length === 0) return;
+ const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(planId) as { year: number }[];
+ for (const cal of calendars) {
+ const country = cal.region.split('-')[0];
+ const region = cal.region.includes('-') ? cal.region : null;
+ for (const { year } of years) {
+ try {
+ const cacheKey = `${year}-${country}`;
+ let holidays = holidayCache.get(cacheKey)?.data as Holiday[] | undefined;
+ if (!holidays) {
+ const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`);
+ holidays = await resp.json() as Holiday[];
+ holidayCache.set(cacheKey, { data: holidays, time: Date.now() });
+ }
+ const hasRegions = holidays.some((h: Holiday) => h.counties && h.counties.length > 0);
+ if (hasRegions && !region) continue;
+ for (const h of holidays) {
+ if (h.global || !h.counties || (region && h.counties.includes(region))) {
+ db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, h.date);
+ db.prepare('DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').run(planId, h.date);
+ }
+ }
+ } catch { /* API error, skip */ }
+ }
+ }
+}
+
+async function migrateHolidayCalendars(planId: number, plan: VacayPlan): Promise {
+ const existing = db.prepare('SELECT id FROM vacay_holiday_calendars WHERE plan_id = ?').get(planId);
+ if (existing) return;
+ if (plan.holidays_enabled && plan.holidays_region) {
+ db.prepare(
+ 'INSERT INTO vacay_holiday_calendars (plan_id, region, label, color, sort_order) VALUES (?, ?, NULL, ?, 0)'
+ ).run(planId, plan.holidays_region, '#fecaca');
+ }
+}
+
const router = express.Router();
router.use(authenticate);
@@ -124,6 +174,8 @@ router.get('/plan', (req: Request, res: Response) => {
WHERE m.user_id = ? AND m.status = 'pending'
`).all(authReq.user.id);
+ const holidayCalendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(activePlanId) as VacayHolidayCalendar[];
+
res.json({
plan: {
...plan,
@@ -131,6 +183,7 @@ router.get('/plan', (req: Request, res: Response) => {
holidays_enabled: !!plan.holidays_enabled,
company_holidays_enabled: !!plan.company_holidays_enabled,
carry_over_enabled: !!plan.carry_over_enabled,
+ holiday_calendars: holidayCalendars,
},
users,
pendingInvites,
@@ -166,30 +219,8 @@ router.put('/plan', async (req: Request, res: Response) => {
}
const updatedPlan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan;
- if (updatedPlan.holidays_enabled && updatedPlan.holidays_region) {
- const country = updatedPlan.holidays_region.split('-')[0];
- const region = updatedPlan.holidays_region.includes('-') ? updatedPlan.holidays_region : null;
- const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(planId) as { year: number }[];
- for (const { year } of years) {
- try {
- const cacheKey = `${year}-${country}`;
- let holidays = holidayCache.get(cacheKey)?.data as Holiday[] | undefined;
- if (!holidays) {
- const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`);
- holidays = await resp.json() as Holiday[];
- holidayCache.set(cacheKey, { data: holidays, time: Date.now() });
- }
- const hasRegions = (holidays as Holiday[]).some((h: Holiday) => h.counties && h.counties.length > 0);
- if (hasRegions && !region) continue;
- for (const h of holidays) {
- if (h.global || !h.counties || (region && h.counties.includes(region))) {
- db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, h.date);
- db.prepare('DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').run(planId, h.date);
- }
- }
- } catch { /* API error, skip */ }
- }
- }
+ await migrateHolidayCalendars(planId, updatedPlan);
+ await applyHolidayCalendars(planId);
if (carry_over_enabled === false) {
db.prepare('UPDATE vacay_user_years SET carried_over = 0 WHERE plan_id = ?').run(planId);
@@ -217,11 +248,58 @@ router.put('/plan', async (req: Request, res: Response) => {
notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings');
const updated = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan;
+ const updatedCalendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(planId) as VacayHolidayCalendar[];
res.json({
- plan: { ...updated, block_weekends: !!updated.block_weekends, holidays_enabled: !!updated.holidays_enabled, company_holidays_enabled: !!updated.company_holidays_enabled, carry_over_enabled: !!updated.carry_over_enabled }
+ plan: { ...updated, block_weekends: !!updated.block_weekends, holidays_enabled: !!updated.holidays_enabled, company_holidays_enabled: !!updated.company_holidays_enabled, carry_over_enabled: !!updated.carry_over_enabled, holiday_calendars: updatedCalendars }
});
});
+router.post('/plan/holiday-calendars', (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { region, label, color, sort_order } = req.body;
+ if (!region) return res.status(400).json({ error: 'region required' });
+ const planId = getActivePlanId(authReq.user.id);
+ const result = db.prepare(
+ 'INSERT INTO vacay_holiday_calendars (plan_id, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?)'
+ ).run(planId, region, label || null, color || '#fecaca', sort_order ?? 0);
+ const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(result.lastInsertRowid) as VacayHolidayCalendar;
+ notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings');
+ res.json({ calendar: cal });
+});
+
+router.put('/plan/holiday-calendars/:id', (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const id = parseInt(req.params.id);
+ const planId = getActivePlanId(authReq.user.id);
+ const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?').get(id, planId) as VacayHolidayCalendar | undefined;
+ if (!cal) return res.status(404).json({ error: 'Calendar not found' });
+ const { region, label, color, sort_order } = req.body;
+ const updates: string[] = [];
+ const params: (string | number | null)[] = [];
+ if (region !== undefined) { updates.push('region = ?'); params.push(region); }
+ if (label !== undefined) { updates.push('label = ?'); params.push(label); }
+ if (color !== undefined) { updates.push('color = ?'); params.push(color); }
+ if (sort_order !== undefined) { updates.push('sort_order = ?'); params.push(sort_order); }
+ if (updates.length > 0) {
+ params.push(id);
+ db.prepare(`UPDATE vacay_holiday_calendars SET ${updates.join(', ')} WHERE id = ?`).run(...params);
+ }
+ const updated = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(id) as VacayHolidayCalendar;
+ notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings');
+ res.json({ calendar: updated });
+});
+
+router.delete('/plan/holiday-calendars/:id', (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const id = parseInt(req.params.id);
+ const planId = getActivePlanId(authReq.user.id);
+ const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?').get(id, planId);
+ if (!cal) return res.status(404).json({ error: 'Calendar not found' });
+ db.prepare('DELETE FROM vacay_holiday_calendars WHERE id = ?').run(id);
+ notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings');
+ res.json({ success: true });
+});
+
router.put('/color', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { color, target_user_id } = req.body;