+
+
+
+
+ {/* Left column: Members */}
+
{/* Trip name */}
@@ -228,6 +355,13 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
)}
+
+
+ {/* Right column: Share Link */}
+
+
+
+
diff --git a/client/src/components/Vacay/VacayMonthCard.tsx b/client/src/components/Vacay/VacayMonthCard.tsx
index cedf7d9..cc9a77f 100644
--- a/client/src/components/Vacay/VacayMonthCard.tsx
+++ b/client/src/components/Vacay/VacayMonthCard.tsx
@@ -3,19 +3,7 @@ import { useTranslation } from '../../i18n'
import { isWeekend } from './holidays'
import type { HolidaysMap, VacayEntry } from '../../types'
-const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
-const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
-const WEEKDAYS_ES = ['Lu', 'Ma', 'Mi', 'Ju', 'Vi', 'Sa', 'Do']
-const WEEKDAYS_FR = ['Lu', 'Ma', 'Me', 'Je', 'Ve', 'Sa', 'Di']
-const WEEKDAYS_BR = ['Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb', 'Dom']
-const WEEKDAYS_AR = ['اث', 'ثل', 'أر', 'خم', 'جم', 'سب', 'أح']
-
-const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
-const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
-const MONTHS_ES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre']
-const MONTHS_FR = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
-const MONTHS_BR = ['Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro']
-const MONTHS_AR = ['يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر']
+const WEEKDAY_KEYS = ['vacay.mon', 'vacay.tue', 'vacay.wed', 'vacay.thu', 'vacay.fri', 'vacay.sat', 'vacay.sun'] as const
function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16)
@@ -41,10 +29,10 @@ export default function VacayMonthCard({
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
onCellClick, companyMode, blockWeekends, weekendDays = [0, 6]
}: VacayMonthCardProps) {
- const { language } = useTranslation()
-
- const weekdays = language === 'de' ? WEEKDAYS_DE : language === 'es' ? WEEKDAYS_ES : language === 'fr' ? WEEKDAYS_FR : language === 'br' ? WEEKDAYS_BR : language === 'ar' ? WEEKDAYS_AR : WEEKDAYS_EN
- const monthNames = language === 'de' ? MONTHS_DE : language === 'es' ? MONTHS_ES : language === 'fr' ? MONTHS_FR : language === 'br' ? MONTHS_BR : language === 'ar' ? MONTHS_AR : MONTHS_EN
+ const { t, locale } = useTranslation()
+
+ const weekdays = WEEKDAY_KEYS.map(k => t(k))
+ const monthName = useMemo(() => new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(year, month, 1)), [locale, year, month])
const weeks = useMemo(() => {
const firstDay = new Date(year, month, 1)
@@ -65,7 +53,7 @@ export default function VacayMonthCard({
return (
- {monthNames[month]}
+ {monthName}
diff --git a/client/src/components/Vacay/holidays.ts b/client/src/components/Vacay/holidays.ts
index 6294142..fe5903b 100644
--- a/client/src/components/Vacay/holidays.ts
+++ b/client/src/components/Vacay/holidays.ts
@@ -122,9 +122,9 @@ export function daysInMonth(year: number, month: number): number {
return new Date(year, month, 0).getDate()
}
-export function formatDate(dateStr: string): string {
+export function formatDate(dateStr: string, locale?: string): string {
const d = new Date(dateStr + 'T00:00:00')
- return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
+ return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
}
export { BUNDESLAENDER }
diff --git a/client/src/components/shared/CustomSelect.tsx b/client/src/components/shared/CustomSelect.tsx
index da4d193..2df9c5e 100644
--- a/client/src/components/shared/CustomSelect.tsx
+++ b/client/src/components/shared/CustomSelect.tsx
@@ -107,9 +107,15 @@ export default function CustomSelect({
{open && ReactDOM.createPortal(
{ const r = ref.current?.getBoundingClientRect(); return r ? r.bottom + 4 : 0 })(),
- left: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.left : 0 })(),
- width: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.width : 200 })(),
+ ...(() => {
+ const r = ref.current?.getBoundingClientRect()
+ if (!r) return { top: 0, left: 0, width: 200 }
+ const spaceBelow = window.innerHeight - r.bottom
+ const openUp = spaceBelow < 220 && r.top > spaceBelow
+ return openUp
+ ? { bottom: window.innerHeight - r.top + 4, left: r.left, width: r.width }
+ : { top: r.bottom + 4, left: r.left, width: r.width }
+ })(),
zIndex: 99999,
background: 'var(--bg-card)',
backdropFilter: 'blur(24px) saturate(180%)',
diff --git a/client/src/components/shared/Modal.tsx b/client/src/components/shared/Modal.tsx
index 7b39e1f..5b8124b 100644
--- a/client/src/components/shared/Modal.tsx
+++ b/client/src/components/shared/Modal.tsx
@@ -7,6 +7,7 @@ const sizeClasses: Record
= {
lg: 'max-w-lg',
xl: 'max-w-2xl',
'2xl': 'max-w-4xl',
+ '3xl': 'max-w-5xl',
}
interface ModalProps {
diff --git a/client/src/components/shared/PlaceAvatar.tsx b/client/src/components/shared/PlaceAvatar.tsx
index c250160..027e411 100644
--- a/client/src/components/shared/PlaceAvatar.tsx
+++ b/client/src/components/shared/PlaceAvatar.tsx
@@ -16,8 +16,18 @@ interface PlaceAvatarProps {
const photoCache = new Map()
const photoInFlight = new Set()
+// Event-based notification instead of polling intervals
+const photoListeners = new Map void>>()
-export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
+function notifyListeners(key: string, url: string | null) {
+ const listeners = photoListeners.get(key)
+ if (listeners) {
+ listeners.forEach(fn => fn(url))
+ photoListeners.delete(key)
+ }
+}
+
+export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
const [photoSrc, setPhotoSrc] = useState(place.image_url || null)
useEffect(() => {
@@ -33,28 +43,27 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP
}
if (photoInFlight.has(cacheKey)) {
- // Another instance is already fetching, wait for it
- const check = setInterval(() => {
- if (photoCache.has(cacheKey)) {
- clearInterval(check)
- const cached = photoCache.get(cacheKey)
- if (cached) setPhotoSrc(cached)
- }
- }, 200)
- return () => clearInterval(check)
+ // Subscribe to notification instead of polling
+ if (!photoListeners.has(cacheKey)) photoListeners.set(cacheKey, new Set())
+ const handler = (url: string | null) => { if (url) setPhotoSrc(url) }
+ photoListeners.get(cacheKey)!.add(handler)
+ return () => { photoListeners.get(cacheKey)?.delete(handler) }
}
+
photoInFlight.add(cacheKey)
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
.then((data: { photoUrl?: string }) => {
- if (data.photoUrl) {
- photoCache.set(cacheKey, data.photoUrl)
- setPhotoSrc(data.photoUrl)
- } else {
- photoCache.set(cacheKey, null)
- }
+ const url = data.photoUrl || null
+ photoCache.set(cacheKey, url)
+ if (url) setPhotoSrc(url)
+ notifyListeners(cacheKey, url)
+ photoInFlight.delete(cacheKey)
+ })
+ .catch(() => {
+ photoCache.set(cacheKey, null)
+ notifyListeners(cacheKey, null)
photoInFlight.delete(cacheKey)
})
- .catch(() => { photoCache.set(cacheKey, null); photoInFlight.delete(cacheKey) })
}, [place.id, place.image_url, place.google_place_id, place.osm_id])
const bgColor = category?.color || '#6366f1'
@@ -76,6 +85,7 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP
setPhotoSrc(null)}
/>
@@ -88,4 +98,4 @@ export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarP
)
-}
+})
diff --git a/client/src/i18n/TranslationContext.tsx b/client/src/i18n/TranslationContext.tsx
index 64dd34c..f9ef45e 100644
--- a/client/src/i18n/TranslationContext.tsx
+++ b/client/src/i18n/TranslationContext.tsx
@@ -4,11 +4,14 @@ import de from './translations/de'
import en from './translations/en'
import es from './translations/es'
import fr from './translations/fr'
+import hu from './translations/hu'
+import it from './translations/it'
import ru from './translations/ru'
import zh from './translations/zh'
import nl from './translations/nl'
import ar from './translations/ar'
import br from './translations/br'
+import cs from './translations/cs'
type TranslationStrings = Record
@@ -17,15 +20,18 @@ export const SUPPORTED_LANGUAGES = [
{ value: 'en', label: 'English' },
{ value: 'es', label: 'Español' },
{ value: 'fr', label: 'Français' },
+ { value: 'hu', label: 'Magyar' },
{ value: 'nl', label: 'Nederlands' },
{ value: 'br', label: 'Português (Brasil)' },
+ { value: 'cs', label: 'Česky' },
{ value: 'ru', label: 'Русский' },
{ value: 'zh', label: '中文' },
+ { value: 'it', label: 'Italiano' },
{ value: 'ar', label: 'العربية' },
] as const
-const translations: Record = { de, en, es, fr, ru, zh, nl, ar, br }
-const LOCALES: Record = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', ru: 'ru-RU', zh: 'zh-CN', nl: 'nl-NL', ar: 'ar-SA', br: 'pt-BR' }
+const translations: Record = { de, en, es, fr, hu, it, ru, zh, nl, ar, br, cs }
+const LOCALES: Record = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', hu: 'hu-HU', it: 'it-IT', ru: 'ru-RU', zh: 'zh-CN', nl: 'nl-NL', ar: 'ar-SA', br: 'pt-BR', cs: 'cs-CZ' }
const RTL_LANGUAGES = new Set(['ar'])
export function getLocaleForLanguage(language: string): string {
@@ -34,7 +40,7 @@ export function getLocaleForLanguage(language: string): string {
export function getIntlLanguage(language: string): string {
if (language === 'br') return 'pt-BR'
- return ['de', 'es', 'fr', 'ru', 'zh', 'nl', 'ar'].includes(language) ? language : 'en'
+ return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'nl', 'ar', 'cs'].includes(language) ? language : 'en'
}
export function isRtlLanguage(language: string): boolean {
diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts
index e4d5070..7449336 100644
--- a/client/src/i18n/translations/ar.ts
+++ b/client/src/i18n/translations/ar.ts
@@ -144,8 +144,77 @@ const ar: Record = {
'settings.temperature': 'وحدة الحرارة',
'settings.timeFormat': 'تنسيق الوقت',
'settings.routeCalculation': 'حساب المسار',
+ 'settings.blurBookingCodes': 'إخفاء رموز الحجز',
+ 'settings.notifications': 'الإشعارات',
+ 'settings.notifyTripInvite': 'دعوات الرحلات',
+ 'settings.notifyBookingChange': 'تغييرات الحجز',
+ 'settings.notifyTripReminder': 'تذكيرات الرحلات',
+ 'settings.notifyVacayInvite': 'دعوات دمج الإجازات',
+ 'settings.notifyPhotosShared': 'صور مشتركة (Immich)',
+ 'settings.notifyCollabMessage': 'رسائل الدردشة (Collab)',
+ 'settings.notifyPackingTagged': 'قائمة الأمتعة: التعيينات',
+ 'settings.notifyWebhook': 'إشعارات Webhook',
+ 'admin.smtp.title': 'البريد والإشعارات',
+ 'admin.smtp.hint': 'تكوين SMTP لإشعارات البريد الإلكتروني. اختياري: عنوان Webhook لـ Discord أو Slack وغيرها.',
+ 'admin.smtp.testButton': 'إرسال بريد تجريبي',
+ 'admin.smtp.testSuccess': 'تم إرسال البريد التجريبي بنجاح',
+ 'admin.smtp.testFailed': 'فشل إرسال البريد التجريبي',
+ 'dayplan.icsTooltip': 'تصدير التقويم (ICS)',
+ 'share.linkTitle': 'رابط عام',
+ 'share.linkHint': 'أنشئ رابطًا يمكن لأي شخص استخدامه لعرض هذه الرحلة بدون تسجيل الدخول. للقراءة فقط — لا يمكن التعديل.',
+ 'share.createLink': 'إنشاء رابط',
+ 'share.deleteLink': 'حذف الرابط',
+ 'share.createError': 'تعذر إنشاء الرابط',
+ 'common.copy': 'نسخ',
+ 'common.copied': 'تم النسخ',
+ 'share.permMap': 'الخريطة والخطة',
+ 'share.permBookings': 'الحجوزات',
+ 'share.permPacking': 'الأمتعة',
+ 'shared.expired': 'الرابط منتهي أو غير صالح',
+ 'shared.expiredHint': 'رابط الرحلة المشترك لم يعد نشطًا.',
+ 'shared.readOnly': 'عرض للقراءة فقط',
+ 'shared.tabPlan': 'الخطة',
+ 'shared.tabBookings': 'الحجوزات',
+ 'shared.tabPacking': 'قائمة التعبئة',
+ 'shared.tabBudget': 'الميزانية',
+ 'shared.tabChat': 'الدردشة',
+ 'shared.days': 'أيام',
+ 'shared.places': 'أماكن',
+ 'shared.other': 'أخرى',
+ 'shared.totalBudget': 'إجمالي الميزانية',
+ 'shared.messages': 'رسائل',
+ 'shared.sharedVia': 'تمت المشاركة عبر',
+ 'shared.confirmed': 'مؤكد',
+ 'shared.pending': 'قيد الانتظار',
+ 'share.permBudget': 'الميزانية',
+ 'share.permCollab': 'الدردشة',
'settings.on': 'تشغيل',
'settings.off': 'إيقاف',
+ 'settings.mcp.title': 'إعداد MCP',
+ 'settings.mcp.endpoint': 'نقطة نهاية MCP',
+ 'settings.mcp.clientConfig': 'إعداد العميل',
+ 'settings.mcp.clientConfigHint': 'استبدل برمز API من القائمة أدناه. قد يحتاج مسار npx إلى ضبط وفق نظامك (مثلاً C:\\PROGRA~1\\nodejs\\npx.cmd على Windows).',
+ 'settings.mcp.copy': 'نسخ',
+ 'settings.mcp.copied': 'تم النسخ!',
+ 'settings.mcp.apiTokens': 'رموز API',
+ 'settings.mcp.createToken': 'إنشاء رمز جديد',
+ 'settings.mcp.noTokens': 'لا توجد رموز بعد. أنشئ رمزاً للاتصال بعملاء MCP.',
+ 'settings.mcp.tokenCreatedAt': 'أُنشئ',
+ 'settings.mcp.tokenUsedAt': 'استُخدم',
+ 'settings.mcp.deleteTokenTitle': 'حذف الرمز',
+ 'settings.mcp.deleteTokenMessage': 'سيتوقف هذا الرمز عن العمل فوراً. أي عميل MCP يستخدمه سيفقد الوصول.',
+ 'settings.mcp.modal.createTitle': 'إنشاء رمز API',
+ 'settings.mcp.modal.tokenName': 'اسم الرمز',
+ 'settings.mcp.modal.tokenNamePlaceholder': 'مثال: Claude Desktop، حاسوب العمل',
+ 'settings.mcp.modal.creating': 'جارٍ الإنشاء…',
+ 'settings.mcp.modal.create': 'إنشاء الرمز',
+ 'settings.mcp.modal.createdTitle': 'تم إنشاء الرمز',
+ 'settings.mcp.modal.createdWarning': 'سيُعرض هذا الرمز مرة واحدة فقط. انسخه واحفظه الآن — لا يمكن استرداده.',
+ 'settings.mcp.modal.done': 'تم',
+ 'settings.mcp.toast.created': 'تم إنشاء الرمز',
+ 'settings.mcp.toast.createError': 'فشل إنشاء الرمز',
+ 'settings.mcp.toast.deleted': 'تم حذف الرمز',
+ 'settings.mcp.toast.deleteError': 'فشل حذف الرمز',
'settings.account': 'الحساب',
'settings.username': 'اسم المستخدم',
'settings.email': 'البريد الإلكتروني',
@@ -182,6 +251,14 @@ const ar: Record = {
'settings.avatarError': 'فشل الرفع',
'settings.mfa.title': 'المصادقة الثنائية (2FA)',
'settings.mfa.description': 'تضيف خطوة ثانية عند تسجيل الدخول. استخدم تطبيق مصادقة (Google Authenticator، Authy، إلخ).',
+ 'settings.mfa.requiredByPolicy': 'المسؤول يتطلب المصادقة الثنائية. اضبط تطبيق المصادقة أدناه قبل المتابعة.',
+ 'settings.mfa.backupTitle': 'رموز النسخ الاحتياطي',
+ 'settings.mfa.backupDescription': 'استخدم هذه الرموز لمرة واحدة إذا فقدت الوصول إلى تطبيق المصادقة.',
+ 'settings.mfa.backupWarning': 'احفظ هذه الرموز الآن. كل رمز يمكن استخدامه مرة واحدة فقط.',
+ 'settings.mfa.backupCopy': 'نسخ الرموز',
+ 'settings.mfa.backupDownload': 'تنزيل TXT',
+ 'settings.mfa.backupPrint': 'طباعة / PDF',
+ 'settings.mfa.backupCopied': 'تم نسخ رموز النسخ الاحتياطي',
'settings.mfa.enabled': 'المصادقة الثنائية مفعّلة على حسابك.',
'settings.mfa.disabled': 'المصادقة الثنائية غير مفعّلة.',
'settings.mfa.setup': 'إعداد المصادقة',
@@ -276,10 +353,25 @@ const ar: Record = {
'admin.tabs.users': 'المستخدمون',
'admin.tabs.categories': 'الفئات',
'admin.tabs.backup': 'النسخ الاحتياطي',
+ 'admin.tabs.audit': 'سجل التدقيق',
'admin.tabs.settings': 'الإعدادات',
'admin.tabs.config': 'الإعدادات',
'admin.tabs.templates': 'قوالب التعبئة',
'admin.tabs.addons': 'الإضافات',
+ 'admin.tabs.mcpTokens': 'رموز MCP',
+ 'admin.mcpTokens.title': 'رموز MCP',
+ 'admin.mcpTokens.subtitle': 'إدارة رموز API لجميع المستخدمين',
+ 'admin.mcpTokens.owner': 'المالك',
+ 'admin.mcpTokens.tokenName': 'اسم الرمز',
+ 'admin.mcpTokens.created': 'تاريخ الإنشاء',
+ 'admin.mcpTokens.lastUsed': 'آخر استخدام',
+ 'admin.mcpTokens.never': 'أبداً',
+ 'admin.mcpTokens.empty': 'لم يتم إنشاء أي رموز MCP بعد',
+ 'admin.mcpTokens.deleteTitle': 'حذف الرمز',
+ 'admin.mcpTokens.deleteMessage': 'سيتم إلغاء هذا الرمز فوراً. سيفقد المستخدم وصوله إلى MCP عبر هذا الرمز.',
+ 'admin.mcpTokens.deleteSuccess': 'تم حذف الرمز',
+ 'admin.mcpTokens.deleteError': 'فشل حذف الرمز',
+ 'admin.mcpTokens.loadError': 'فشل تحميل الرموز',
'admin.tabs.github': 'GitHub',
'admin.stats.users': 'المستخدمون',
'admin.stats.trips': 'الرحلات',
@@ -329,6 +421,8 @@ const ar: Record = {
'admin.invite.deleteError': 'فشل حذف رابط الدعوة',
'admin.allowRegistration': 'السماح بالتسجيل',
'admin.allowRegistrationHint': 'يمكن للمستخدمين الجدد التسجيل بأنفسهم',
+ 'admin.requireMfa': 'فرض المصادقة الثنائية (2FA)',
+ 'admin.requireMfaHint': 'يجب على المستخدمين الذين لا يملكون 2FA إكمال الإعداد في الإعدادات قبل استخدام التطبيق.',
'admin.apiKeys': 'مفاتيح API',
'admin.apiKeysHint': 'اختياري. يُفعّل بيانات الأماكن الموسعة مثل الصور والطقس.',
'admin.mapsKey': 'مفتاح Google Maps API',
@@ -382,6 +476,8 @@ const ar: Record = {
'admin.addons.subtitle': 'فعّل أو عطّل الميزات لتخصيص تجربة TREK.',
'admin.addons.catalog.memories.name': 'صور (Immich)',
'admin.addons.catalog.memories.description': 'شارك صور رحلتك عبر Immich',
+ 'admin.addons.catalog.mcp.name': 'MCP',
+ 'admin.addons.catalog.mcp.description': 'بروتوكول سياق النموذج لتكامل مساعد الذكاء الاصطناعي',
'admin.addons.catalog.packing.name': 'التعبئة',
'admin.addons.catalog.packing.description': 'قوائم تحقق لإعداد أمتعتك لكل رحلة',
'admin.addons.catalog.budget.name': 'الميزانية',
@@ -400,8 +496,10 @@ const ar: Record = {
'admin.addons.disabled': 'معطّل',
'admin.addons.type.trip': 'رحلة',
'admin.addons.type.global': 'عام',
+ 'admin.addons.type.integration': 'تكامل',
'admin.addons.tripHint': 'متاح كعلامة تبويب داخل كل رحلة',
'admin.addons.globalHint': 'متاح كقسم مستقل في التنقل الرئيسي',
+ 'admin.addons.integrationHint': 'خدمات الواجهة الخلفية وتكاملات API بدون صفحة مخصصة',
'admin.addons.toast.updated': 'تم تحديث الإضافة',
'admin.addons.toast.error': 'فشل تحديث الإضافة',
'admin.addons.noAddons': 'لا توجد إضافات متاحة',
@@ -419,6 +517,18 @@ const ar: Record = {
'admin.weather.locationHint': 'يعتمد الطقس على أول مكان بإحداثيات في كل يوم. إذا لم يكن هناك مكان مخصص ليوم ما، يُستخدم أي مكان من قائمة الأماكن كمرجع.',
// GitHub
+ 'admin.audit.subtitle': 'أحداث الأمان والإدارة (النسخ الاحتياطية، المستخدمون، المصادقة الثنائية، الإعدادات).',
+ 'admin.audit.empty': 'لا توجد سجلات تدقيق بعد.',
+ 'admin.audit.refresh': 'تحديث',
+ 'admin.audit.loadMore': 'تحميل المزيد',
+ 'admin.audit.showing': 'تم تحميل {count} · الإجمالي {total}',
+ 'admin.audit.col.time': 'الوقت',
+ 'admin.audit.col.user': 'المستخدم',
+ 'admin.audit.col.action': 'الإجراء',
+ 'admin.audit.col.resource': 'المورد',
+ 'admin.audit.col.ip': 'عنوان IP',
+ 'admin.audit.col.details': 'التفاصيل',
+
'admin.github.title': 'سجل الإصدارات',
'admin.github.subtitle': 'آخر التحديثات من {repo}',
'admin.github.latest': 'الأحدث',
@@ -482,6 +592,14 @@ const ar: Record = {
'vacay.carriedOver': 'من {year}',
'vacay.blockWeekends': 'حظر عطلة نهاية الأسبوع',
'vacay.blockWeekendsHint': 'منع إدخالات الإجازة يومي السبت والأحد',
+ 'vacay.weekendDays': 'أيام عطلة نهاية الأسبوع',
+ 'vacay.mon': 'الاثنين',
+ 'vacay.tue': 'الثلاثاء',
+ 'vacay.wed': 'الأربعاء',
+ 'vacay.thu': 'الخميس',
+ 'vacay.fri': 'الجمعة',
+ 'vacay.sat': 'السبت',
+ 'vacay.sun': 'الأحد',
'vacay.publicHolidays': 'العطل الرسمية',
'vacay.publicHolidaysHint': 'وضع علامة على العطل الرسمية في التقويم',
'vacay.selectCountry': 'اختر الدولة',
@@ -539,6 +657,10 @@ const ar: Record = {
'atlas.markVisited': 'تعيين كمُزار',
'atlas.markVisitedHint': 'إضافة هذا البلد إلى قائمة المُزارة',
'atlas.addToBucket': 'إضافة إلى قائمة الأمنيات',
+ 'atlas.addPoi': 'إضافة مكان',
+ 'atlas.bucketNamePlaceholder': 'الاسم (بلد، مدينة، مكان…)',
+ 'atlas.month': 'الشهر',
+ 'atlas.year': 'السنة',
'atlas.addToBucketHint': 'حفظ كمكان تريد زيارته',
'atlas.bucketWhen': 'متى تخطط للزيارة؟',
'atlas.statsTab': 'الإحصائيات',
@@ -624,14 +746,26 @@ const ar: Record = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'تصدير خطة اليوم بصيغة PDF',
'dayplan.pdfError': 'فشل تصدير PDF',
+ 'dayplan.cannotReorderTransport': 'لا يمكن إعادة ترتيب الحجوزات ذات الوقت الثابت',
+ 'dayplan.confirmRemoveTimeTitle': 'إزالة الوقت؟',
+ 'dayplan.confirmRemoveTimeBody': 'هذا المكان له وقت ثابت ({time}). نقله سيزيل الوقت ويسمح بالترتيب الحر.',
+ 'dayplan.confirmRemoveTimeAction': 'إزالة الوقت ونقل',
+ 'dayplan.cannotDropOnTimed': 'لا يمكن وضع العناصر بين الإدخالات المرتبطة بوقت',
+ 'dayplan.cannotBreakChronology': 'سيؤدي هذا إلى كسر الترتيب الزمني للعناصر والحجوزات المجدولة',
// Places Sidebar
'places.addPlace': 'إضافة مكان/نشاط',
+ 'places.importGpx': 'استيراد GPX',
+ 'places.gpxImported': 'تم استيراد {count} مكان من GPX',
+ 'places.gpxError': 'فشل استيراد GPX',
+ 'places.urlResolved': 'تم استيراد المكان من الرابط',
'places.assignToDay': 'إلى أي يوم تريد الإضافة؟',
'places.all': 'الكل',
'places.unplanned': 'غير مخطط',
'places.search': 'ابحث عن أماكن...',
'places.allCategories': 'كل الفئات',
+ 'places.categoriesSelected': 'فئات',
+ 'places.clearFilter': 'مسح الفلتر',
'places.count': '{count} أماكن',
'places.countSingular': 'مكان واحد',
'places.allPlanned': 'تم تخطيط جميع الأماكن',
@@ -731,6 +865,8 @@ const ar: Record = {
'reservations.type.tour': 'جولة',
'reservations.type.other': 'أخرى',
'reservations.confirm.delete': 'هل تريد حذف الحجز "{name}"؟',
+ 'reservations.confirm.deleteTitle': 'حذف الحجز؟',
+ 'reservations.confirm.deleteBody': 'سيتم حذف "{name}" نهائيًا.',
'reservations.toast.updated': 'تم تحديث الحجز',
'reservations.toast.removed': 'تم حذف الحجز',
'reservations.toast.fileUploaded': 'تم رفع الملف',
@@ -788,6 +924,9 @@ const ar: Record = {
'budget.paid': 'مدفوع',
'budget.open': 'مفتوح',
'budget.noMembers': 'لا أعضاء معينون',
+ 'budget.settlement': 'التسوية',
+ 'budget.settlementInfo': 'انقر على صورة العضو في بند الميزانية لتحديده باللون الأخضر — وهذا يعني أنه دفع. ثم تُظهر التسوية من يدين لمن وبكم.',
+ 'budget.netBalances': 'الأرصدة الصافية',
// Files
'files.title': 'الملفات',
@@ -841,6 +980,15 @@ const ar: Record = {
// Packing
'packing.title': 'قائمة التجهيز',
'packing.empty': 'قائمة التجهيز فارغة',
+ 'packing.import': 'استيراد',
+ 'packing.importTitle': 'استيراد قائمة التعبئة',
+ 'packing.importHint': 'عنصر واحد لكل سطر. يمكن إضافة الفئة والكمية مفصولة بفاصلة أو فاصلة منقوطة أو علامة تبويب: الاسم، الفئة، الكمية',
+ 'packing.importPlaceholder': 'فرشاة أسنان\nواقي شمس، نظافة\nقمصان، ملابس، 5\nجواز سفر، مستندات',
+ 'packing.importCsv': 'تحميل CSV/TXT',
+ 'packing.importAction': 'استيراد {count}',
+ 'packing.importSuccess': 'تم استيراد {count} عنصر',
+ 'packing.importError': 'فشل الاستيراد',
+ 'packing.importEmpty': 'لا توجد عناصر للاستيراد',
'packing.progress': '{packed} من {total} جُهّز ({percent}%)',
'packing.clearChecked': 'إزالة {count} محدد',
'packing.clearCheckedShort': 'إزالة {count}',
@@ -992,7 +1140,27 @@ const ar: Record = {
'backup.auto.enable': 'تفعيل النسخ التلقائي',
'backup.auto.enableHint': 'سيتم إنشاء نسخ احتياطية تلقائيًا وفق الجدول المختار',
'backup.auto.interval': 'الفترة',
+ 'backup.auto.hour': 'التنفيذ في الساعة',
+ 'backup.auto.hourHint': 'التوقيت المحلي للخادم (تنسيق {format})',
+ 'backup.auto.dayOfWeek': 'يوم الأسبوع',
+ 'backup.auto.dayOfMonth': 'يوم الشهر',
+ 'backup.auto.dayOfMonthHint': 'محدود بين 1–28 للتوافق مع جميع الأشهر',
+ 'backup.auto.scheduleSummary': 'الجدول',
+ 'backup.auto.summaryDaily': 'كل يوم الساعة {hour}:00',
+ 'backup.auto.summaryWeekly': 'كل {day} الساعة {hour}:00',
+ 'backup.auto.summaryMonthly': 'اليوم {day} من كل شهر الساعة {hour}:00',
+ 'backup.auto.envLocked': 'Docker',
+ 'backup.auto.envLockedHint': 'النسخ الاحتياطي التلقائي مُعدّ عبر متغيرات بيئة Docker. لتعديل الإعدادات، حدّث docker-compose.yml وأعد تشغيل الحاوية.',
+ 'backup.auto.copyEnv': 'نسخ متغيرات بيئة Docker',
+ 'backup.auto.envCopied': 'تم نسخ متغيرات بيئة Docker إلى الحافظة',
'backup.auto.keepLabel': 'حذف النسخ القديمة بعد',
+ 'backup.dow.sunday': 'أحد',
+ 'backup.dow.monday': 'إثن',
+ 'backup.dow.tuesday': 'ثلا',
+ 'backup.dow.wednesday': 'أرب',
+ 'backup.dow.thursday': 'خمي',
+ 'backup.dow.friday': 'جمع',
+ 'backup.dow.saturday': 'سبت',
'backup.interval.hourly': 'كل ساعة',
'backup.interval.daily': 'يوميًا',
'backup.interval.weekly': 'أسبوعيًا',
@@ -1144,6 +1312,19 @@ const ar: Record = {
'memories.oldest': 'الأقدم أولاً',
'memories.newest': 'الأحدث أولاً',
'memories.allLocations': 'جميع المواقع',
+ 'memories.addPhotos': 'إضافة صور',
+ 'memories.selectPhotos': 'اختيار صور من Immich',
+ 'memories.selectHint': 'انقر على الصور لتحديدها.',
+ 'memories.selected': 'محدد',
+ 'memories.addSelected': 'إضافة {count} صور',
+ 'memories.alreadyAdded': 'تمت الإضافة',
+ 'memories.private': 'خاص',
+ 'memories.stopSharing': 'إيقاف المشاركة',
+ 'memories.tripDates': 'تواريخ الرحلة',
+ 'memories.allPhotos': 'جميع الصور',
+ 'memories.confirmShareTitle': 'مشاركة مع أعضاء الرحلة؟',
+ 'memories.confirmShareHint': '{count} صور ستكون مرئية لجميع أعضاء هذه الرحلة. يمكنك جعل الصور الفردية خاصة لاحقًا.',
+ 'memories.confirmShareButton': 'مشاركة الصور',
// Collab Addon
'collab.tabs.chat': 'الدردشة',
diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts
index 969e8a0..f00adb9 100644
--- a/client/src/i18n/translations/br.ts
+++ b/client/src/i18n/translations/br.ts
@@ -139,6 +139,50 @@ const br: Record = {
'settings.temperature': 'Unidade de temperatura',
'settings.timeFormat': 'Formato de hora',
'settings.routeCalculation': 'Cálculo de rota',
+ 'settings.blurBookingCodes': 'Ocultar códigos de reserva',
+ 'settings.notifications': 'Notificações',
+ 'settings.notifyTripInvite': 'Convites de viagem',
+ 'settings.notifyBookingChange': 'Alterações de reserva',
+ 'settings.notifyTripReminder': 'Lembretes de viagem',
+ 'settings.notifyVacayInvite': 'Convites de fusão Vacay',
+ 'settings.notifyPhotosShared': 'Fotos compartilhadas (Immich)',
+ 'settings.notifyCollabMessage': 'Mensagens de chat (Colab)',
+ 'settings.notifyPackingTagged': 'Lista de mala: atribuições',
+ 'settings.notifyWebhook': 'Notificações webhook',
+ 'admin.smtp.title': 'E-mail e notificações',
+ 'admin.smtp.hint': 'Configuração SMTP para notificações por e-mail. Opcional: URL webhook para Discord, Slack, etc.',
+ 'admin.smtp.testButton': 'Enviar e-mail de teste',
+ 'admin.smtp.testSuccess': 'E-mail de teste enviado com sucesso',
+ 'admin.smtp.testFailed': 'Falha ao enviar e-mail de teste',
+ 'dayplan.icsTooltip': 'Exportar calendário (ICS)',
+ 'share.linkTitle': 'Link público',
+ 'share.linkHint': 'Crie um link que qualquer pessoa pode usar para ver esta viagem sem fazer login. Somente leitura — sem edição possível.',
+ 'share.createLink': 'Criar link',
+ 'share.deleteLink': 'Excluir link',
+ 'share.createError': 'Não foi possível criar o link',
+ 'common.copy': 'Copiar',
+ 'common.copied': 'Copiado',
+ 'share.permMap': 'Mapa e plano',
+ 'share.permBookings': 'Reservas',
+ 'share.permPacking': 'Mala',
+ 'shared.expired': 'Link expirado ou inválido',
+ 'shared.expiredHint': 'Este link de viagem compartilhado não está mais ativo.',
+ 'shared.readOnly': 'Visualização somente leitura',
+ 'shared.tabPlan': 'Plano',
+ 'shared.tabBookings': 'Reservas',
+ 'shared.tabPacking': 'Bagagem',
+ 'shared.tabBudget': 'Orçamento',
+ 'shared.tabChat': 'Chat',
+ 'shared.days': 'dias',
+ 'shared.places': 'lugares',
+ 'shared.other': 'Outros',
+ 'shared.totalBudget': 'Orçamento total',
+ 'shared.messages': 'mensagens',
+ 'shared.sharedVia': 'Compartilhado via',
+ 'shared.confirmed': 'Confirmado',
+ 'shared.pending': 'Pendente',
+ 'share.permBudget': 'Orçamento',
+ 'share.permCollab': 'Chat',
'settings.on': 'Ligado',
'settings.off': 'Desligado',
'settings.account': 'Conta',
@@ -177,6 +221,14 @@ const br: Record = {
'settings.avatarError': 'Falha no envio',
'settings.mfa.title': 'Autenticação em duas etapas (2FA)',
'settings.mfa.description': 'Adiciona uma segunda etapa ao entrar com e-mail e senha. Use um app autenticador (Google Authenticator, Authy, etc.).',
+ 'settings.mfa.requiredByPolicy': 'O administrador exige autenticação em dois fatores. Configure um app autenticador abaixo antes de continuar.',
+ 'settings.mfa.backupTitle': 'Códigos de backup',
+ 'settings.mfa.backupDescription': 'Use estes códigos únicos se perder acesso ao app autenticador.',
+ 'settings.mfa.backupWarning': 'Salve estes códigos agora. Cada código pode ser usado apenas uma vez.',
+ 'settings.mfa.backupCopy': 'Copiar códigos',
+ 'settings.mfa.backupDownload': 'Baixar TXT',
+ 'settings.mfa.backupPrint': 'Imprimir / PDF',
+ 'settings.mfa.backupCopied': 'Códigos de backup copiados',
'settings.mfa.enabled': 'O 2FA está ativado na sua conta.',
'settings.mfa.disabled': 'O 2FA não está ativado.',
'settings.mfa.setup': 'Configurar autenticador',
@@ -191,6 +243,31 @@ const br: Record = {
'settings.mfa.toastEnabled': 'Autenticação em duas etapas ativada',
'settings.mfa.toastDisabled': 'Autenticação em duas etapas desativada',
'settings.mfa.demoBlocked': 'Indisponível no modo demonstração',
+ 'settings.mcp.title': 'Configuração MCP',
+ 'settings.mcp.endpoint': 'Endpoint MCP',
+ 'settings.mcp.clientConfig': 'Configuração do cliente',
+ 'settings.mcp.clientConfigHint': 'Substitua por um token de API da lista abaixo. O caminho para o npx pode precisar ser ajustado para o seu sistema (ex.: C:\\PROGRA~1\\nodejs\\npx.cmd no Windows).',
+ 'settings.mcp.copy': 'Copiar',
+ 'settings.mcp.copied': 'Copiado!',
+ 'settings.mcp.apiTokens': 'Tokens de API',
+ 'settings.mcp.createToken': 'Criar novo token',
+ 'settings.mcp.noTokens': 'Nenhum token ainda. Crie um para conectar clientes MCP.',
+ 'settings.mcp.tokenCreatedAt': 'Criado em',
+ 'settings.mcp.tokenUsedAt': 'Usado em',
+ 'settings.mcp.deleteTokenTitle': 'Excluir token',
+ 'settings.mcp.deleteTokenMessage': 'Este token deixará de funcionar imediatamente. Qualquer cliente MCP que o utilize perderá o acesso.',
+ 'settings.mcp.modal.createTitle': 'Criar token de API',
+ 'settings.mcp.modal.tokenName': 'Nome do token',
+ 'settings.mcp.modal.tokenNamePlaceholder': 'ex.: Claude Desktop, Notebook do trabalho',
+ 'settings.mcp.modal.creating': 'Criando…',
+ 'settings.mcp.modal.create': 'Criar token',
+ 'settings.mcp.modal.createdTitle': 'Token criado',
+ 'settings.mcp.modal.createdWarning': 'Este token será exibido apenas uma vez. Copie e guarde agora — não poderá ser recuperado.',
+ 'settings.mcp.modal.done': 'Concluído',
+ 'settings.mcp.toast.created': 'Token criado',
+ 'settings.mcp.toast.createError': 'Falha ao criar token',
+ 'settings.mcp.toast.deleted': 'Token excluído',
+ 'settings.mcp.toast.deleteError': 'Falha ao excluir token',
// Login
'login.error': 'Falha no login. Verifique suas credenciais.',
@@ -320,6 +397,8 @@ const br: Record = {
'admin.tabs.settings': 'Configurações',
'admin.allowRegistration': 'Permitir cadastro',
'admin.allowRegistrationHint': 'Novos usuários podem se cadastrar sozinhos',
+ 'admin.requireMfa': 'Exigir autenticação em dois fatores (2FA)',
+ 'admin.requireMfaHint': 'Usuários sem 2FA precisam concluir a configuração em Configurações antes de usar o app.',
'admin.apiKeys': 'Chaves de API',
'admin.apiKeysHint': 'Opcional. Habilita dados estendidos de lugares, como fotos e clima.',
'admin.mapsKey': 'Chave da API Google Maps',
@@ -388,6 +467,8 @@ const br: Record = {
'admin.addons.catalog.atlas.description': 'Mapa mundial com países visitados e estatísticas',
'admin.addons.catalog.collab.name': 'Colab',
'admin.addons.catalog.collab.description': 'Notas, enquetes e chat em tempo real para planejar a viagem',
+ 'admin.addons.catalog.mcp.name': 'MCP',
+ 'admin.addons.catalog.mcp.description': 'Model Context Protocol para integração com assistentes de IA',
'admin.addons.subtitleBefore': 'Ative ou desative recursos para personalizar sua ',
'admin.addons.subtitleAfter': ' experiência.',
'admin.addons.enabled': 'Ativado',
@@ -411,6 +492,20 @@ const br: Record = {
'admin.weather.requestsDesc': 'Grátis, sem chave de API',
'admin.weather.locationHint': 'O clima usa o primeiro lugar com coordenadas de cada dia. Se nenhum lugar estiver atribuído ao dia, qualquer lugar da lista serve como referência.',
+ 'admin.tabs.audit': 'Log de auditoria',
+
+ 'admin.audit.subtitle': 'Eventos sensíveis de segurança e administração (backups, usuários, 2FA, configurações).',
+ 'admin.audit.empty': 'Nenhum registro de auditoria.',
+ 'admin.audit.refresh': 'Atualizar',
+ 'admin.audit.loadMore': 'Carregar mais',
+ 'admin.audit.showing': '{count} carregados · {total} no total',
+ 'admin.audit.col.time': 'Hora',
+ 'admin.audit.col.user': 'Usuário',
+ 'admin.audit.col.action': 'Ação',
+ 'admin.audit.col.resource': 'Recurso',
+ 'admin.audit.col.ip': 'IP',
+ 'admin.audit.col.details': 'Detalhes',
+
// GitHub
'admin.tabs.github': 'GitHub',
'admin.github.title': 'Histórico de versões',
@@ -542,6 +637,10 @@ const br: Record = {
'atlas.markVisited': 'Marcar como visitado',
'atlas.markVisitedHint': 'Adicionar este país à lista de visitados',
'atlas.addToBucket': 'Adicionar à lista de desejos',
+ 'atlas.addPoi': 'Adicionar lugar',
+ 'atlas.bucketNamePlaceholder': 'Nome (país, cidade, lugar…)',
+ 'atlas.month': 'Mês',
+ 'atlas.year': 'Ano',
'atlas.addToBucketHint': 'Salvar como lugar que você quer visitar',
'atlas.bucketWhen': 'Quando pretende visitar?',
'atlas.statsTab': 'Estatísticas',
@@ -627,14 +726,26 @@ const br: Record = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Exportar plano do dia em PDF',
'dayplan.pdfError': 'Falha ao exportar PDF',
+ 'dayplan.cannotReorderTransport': 'Reservas com horário fixo não podem ser reordenadas',
+ 'dayplan.confirmRemoveTimeTitle': 'Remover horário?',
+ 'dayplan.confirmRemoveTimeBody': 'Este lugar tem um horário fixo ({time}). Movê-lo removerá o horário e permitirá ordenação livre.',
+ 'dayplan.confirmRemoveTimeAction': 'Remover horário e mover',
+ 'dayplan.cannotDropOnTimed': 'Itens não podem ser colocados entre entradas com horário fixo',
+ 'dayplan.cannotBreakChronology': 'Isso quebraria a ordem cronológica dos itens e reservas agendados',
// Places Sidebar
'places.addPlace': 'Adicionar lugar/atividade',
+ 'places.importGpx': 'Importar GPX',
+ 'places.gpxImported': '{count} lugares importados do GPX',
+ 'places.gpxError': 'Falha ao importar GPX',
+ 'places.urlResolved': 'Lugar importado da URL',
'places.assignToDay': 'Adicionar a qual dia?',
'places.all': 'Todos',
'places.unplanned': 'Não planejados',
'places.search': 'Buscar lugares...',
'places.allCategories': 'Todas as categorias',
+ 'places.categoriesSelected': 'categorias',
+ 'places.clearFilter': 'Limpar filtro',
'places.count': '{count} lugares',
'places.countSingular': '1 lugar',
'places.allPlanned': 'Todos os lugares estão planejados',
@@ -733,6 +844,8 @@ const br: Record = {
'reservations.type.tour': 'Passeio',
'reservations.type.other': 'Outro',
'reservations.confirm.delete': 'Tem certeza de que deseja excluir a reserva "{name}"?',
+ 'reservations.confirm.deleteTitle': 'Excluir reserva?',
+ 'reservations.confirm.deleteBody': '"{name}" será excluído permanentemente.',
'reservations.toast.updated': 'Reserva atualizada',
'reservations.toast.removed': 'Reserva excluída',
'reservations.toast.fileUploaded': 'Arquivo enviado',
@@ -790,6 +903,9 @@ const br: Record = {
'budget.paid': 'Pago',
'budget.open': 'Em aberto',
'budget.noMembers': 'Nenhum membro atribuído',
+ 'budget.settlement': 'Acerto',
+ 'budget.settlementInfo': 'Clique no avatar de um membro em um item do orçamento para marcá-lo em verde — significa que ele pagou. O acerto mostra quem deve quanto a quem.',
+ 'budget.netBalances': 'Saldos líquidos',
// Files
'files.title': 'Arquivos',
@@ -843,6 +959,15 @@ const br: Record = {
// Packing
'packing.title': 'Lista de mala',
'packing.empty': 'A lista de mala está vazia',
+ 'packing.import': 'Importar',
+ 'packing.importTitle': 'Importar lista de bagagem',
+ 'packing.importHint': 'Um item por linha. Formato: Categoria, Nome, Peso (g), Bolsa, checked/unchecked (opcional)',
+ 'packing.importPlaceholder': 'Higiene, Escova de dentes\nRoupas, Camisetas, 200\nDocumentos, Passaporte, , Mala de mão\nEletrônicos, Carregador, 50, Mala, checked',
+ 'packing.importCsv': 'Carregar CSV/TXT',
+ 'packing.importAction': 'Importar {count}',
+ 'packing.importSuccess': '{count} itens importados',
+ 'packing.importError': 'Falha na importação',
+ 'packing.importEmpty': 'Nenhum item para importar',
'packing.progress': '{packed} de {total} na mala ({percent}%)',
'packing.clearChecked': 'Remover {count} marcado(s)',
'packing.clearCheckedShort': 'Remover {count}',
@@ -994,7 +1119,27 @@ const br: Record = {
'backup.auto.enable': 'Ativar backup automático',
'backup.auto.enableHint': 'Backups serão criados automaticamente conforme a agenda escolhida',
'backup.auto.interval': 'Intervalo',
+ 'backup.auto.hour': 'Executar no horário',
+ 'backup.auto.hourHint': 'Horário local do servidor (formato {format})',
+ 'backup.auto.dayOfWeek': 'Dia da semana',
+ 'backup.auto.dayOfMonth': 'Dia do mês',
+ 'backup.auto.dayOfMonthHint': 'Limitado a 1–28 para compatibilidade com todos os meses',
+ 'backup.auto.scheduleSummary': 'Agenda',
+ 'backup.auto.summaryDaily': 'Todos os dias às {hour}:00',
+ 'backup.auto.summaryWeekly': 'Toda {day} às {hour}:00',
+ 'backup.auto.summaryMonthly': 'Dia {day} de cada mês às {hour}:00',
+ 'backup.auto.envLocked': 'Docker',
+ 'backup.auto.envLockedHint': 'O backup automático é configurado via variáveis de ambiente Docker. Para alterar essas configurações, atualize o docker-compose.yml e reinicie o contêiner.',
+ 'backup.auto.copyEnv': 'Copiar variáveis de ambiente Docker',
+ 'backup.auto.envCopied': 'Variáveis de ambiente Docker copiadas para a área de transferência',
'backup.auto.keepLabel': 'Excluir backups antigos após',
+ 'backup.dow.sunday': 'Dom',
+ 'backup.dow.monday': 'Seg',
+ 'backup.dow.tuesday': 'Ter',
+ 'backup.dow.wednesday': 'Qua',
+ 'backup.dow.thursday': 'Qui',
+ 'backup.dow.friday': 'Sex',
+ 'backup.dow.saturday': 'Sáb',
'backup.interval.hourly': 'A cada hora',
'backup.interval.daily': 'Diário',
'backup.interval.weekly': 'Semanal',
diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts
new file mode 100644
index 0000000..856098f
--- /dev/null
+++ b/client/src/i18n/translations/cs.ts
@@ -0,0 +1,1400 @@
+const cs: Record = {
+ // Společné (Common)
+ 'common.save': 'Uložit',
+ 'common.cancel': 'Zrušit',
+ 'common.delete': 'Smazat',
+ 'common.edit': 'Upravit',
+ 'common.add': 'Přidat',
+ 'common.loading': 'Načítání...',
+ 'common.error': 'Chyba',
+ 'common.back': 'Zpět',
+ 'common.all': 'Vše',
+ 'common.close': 'Zavřít',
+ 'common.open': 'Otevřít',
+ 'common.upload': 'Nahrát',
+ 'common.search': 'Hledat',
+ 'common.confirm': 'Potvrdit',
+ 'common.ok': 'OK',
+ 'common.yes': 'Ano',
+ 'common.no': 'Ne',
+ 'common.or': 'nebo',
+ 'common.none': 'Žádné',
+ 'common.date': 'Datum',
+ 'common.rename': 'Přejmenovat',
+ 'common.name': 'Jméno',
+ 'common.email': 'E-mail',
+ 'common.password': 'Heslo',
+ 'common.saving': 'Ukládání...',
+ 'common.update': 'Aktualizovat',
+ 'common.change': 'Změnit',
+ 'common.uploading': 'Nahrávání…',
+ 'common.backToPlanning': 'Zpět k plánování',
+ 'common.reset': 'Resetovat',
+
+ // Navigační lišta (Navbar)
+ 'nav.trip': 'Cesta',
+ 'nav.share': 'Sdílet',
+ 'nav.settings': 'Nastavení',
+ 'nav.admin': 'Administrace',
+ 'nav.logout': 'Odhlásit se',
+ 'nav.lightMode': 'Světlý režim',
+ 'nav.darkMode': 'Tmavý režim',
+ 'nav.autoMode': 'Automatický režim',
+ 'nav.administrator': 'Administrátor',
+ 'nav.myTrips': 'Moje cesty',
+
+ // Přehled (Dashboard)
+ 'dashboard.title': 'Moje cesty',
+ 'dashboard.subtitle.loading': 'Načítání cest...',
+ 'dashboard.subtitle.trips': '{count} cest ({archived} archivováno)',
+ 'dashboard.subtitle.empty': 'Začněte svou první cestu',
+ 'dashboard.subtitle.activeOne': '{count} aktivní cesta',
+ 'dashboard.subtitle.activeMany': '{count} aktivních cest',
+ 'dashboard.subtitle.archivedSuffix': ' · {count} archivováno',
+ 'dashboard.newTrip': 'Nová cesta',
+ 'dashboard.gridView': 'Mřížka',
+ 'dashboard.listView': 'Seznam',
+ 'dashboard.currency': 'Měna',
+ 'dashboard.timezone': 'Časová pásma',
+ 'dashboard.localTime': 'Místní čas',
+ 'dashboard.timezoneCustomTitle': 'Vlastní pásmo',
+ 'dashboard.timezoneCustomLabelPlaceholder': 'Popisek (volitelné)',
+ 'dashboard.timezoneCustomTzPlaceholder': 'např. America/New_York',
+ 'dashboard.timezoneCustomAdd': 'Přidat',
+ 'dashboard.timezoneCustomErrorEmpty': 'Zadejte identifikátor pásma',
+ 'dashboard.timezoneCustomErrorInvalid': 'Neplatné pásmo. Použijte formát jako např. Europe/Prague',
+ 'dashboard.timezoneCustomErrorDuplicate': 'Již bylo přidáno',
+ 'dashboard.emptyTitle': 'Zatím žádné cesty',
+ 'dashboard.emptyText': 'Vytvořte svou první cestu a začněte plánovat!',
+ 'dashboard.emptyButton': 'Vytvořit první cestu',
+ 'dashboard.nextTrip': 'Další cesta',
+ 'dashboard.shared': 'Sdílené',
+ 'dashboard.sharedBy': 'Sdílí {name}',
+ 'dashboard.days': 'Dní',
+ 'dashboard.places': 'Míst',
+ 'dashboard.archive': 'Archivovat',
+ 'dashboard.restore': 'Obnovit',
+ 'dashboard.archived': 'Archivováno',
+ 'dashboard.status.ongoing': 'Probíhající',
+ 'dashboard.status.today': 'Dnes',
+ 'dashboard.status.tomorrow': 'Zítra',
+ 'dashboard.status.past': 'Proběhlé',
+ 'dashboard.status.daysLeft': 'zbývá {count} dní',
+ 'dashboard.toast.loadError': 'Nepodařilo se načíst cesty',
+ 'dashboard.toast.created': 'Cesta byla úspěšně vytvořena!',
+ 'dashboard.toast.createError': 'Nepodařilo se vytvořit cestu',
+ 'dashboard.toast.updated': 'Cesta byla aktualizována!',
+ 'dashboard.toast.updateError': 'Nepodařilo se aktualizovat cestu',
+ 'dashboard.toast.deleted': 'Cesta byla smazána',
+ 'dashboard.toast.deleteError': 'Nepodařilo se smazat cestu',
+ 'dashboard.toast.archived': 'Cesta byla archivována',
+ 'dashboard.toast.archiveError': 'Nepodařilo se archivovat cestu',
+ 'dashboard.toast.restored': 'Cesta byla obnovena',
+ 'dashboard.toast.restoreError': 'Nepodařilo se obnovit cestu',
+ 'dashboard.confirm.delete': 'Smazat cestu „{title}“? Všechna místa a plány budou trvale smazány.',
+ 'dashboard.editTrip': 'Upravit cestu',
+ 'dashboard.createTrip': 'Vytvořit novou cestu',
+ 'dashboard.tripTitle': 'Název',
+ 'dashboard.tripTitlePlaceholder': 'např. Léto v Japonsku',
+ 'dashboard.tripDescription': 'Popis',
+ 'dashboard.tripDescriptionPlaceholder': 'O čem je tato cesta?',
+ 'dashboard.startDate': 'Datum začátku',
+ 'dashboard.endDate': 'Datum konce',
+ 'dashboard.noDateHint': 'Datum nezadáno – výchozí délka nastavena na 7 dní. Toto lze kdykoli změnit.',
+ 'dashboard.coverImage': 'Úvodní obrázek',
+ 'dashboard.addCoverImage': 'Vybrat úvodní obrázek (nebo přetáhnout sem)',
+ 'dashboard.addMembers': 'Spolucestující',
+ 'dashboard.addMember': 'Přidat člena',
+ 'dashboard.coverSaved': 'Úvodní obrázek uložen',
+ 'dashboard.coverUploadError': 'Nahrávání se nezdařilo',
+ 'dashboard.coverRemoveError': 'Odstranění se nezdařilo',
+ 'dashboard.titleRequired': 'Název je povinný',
+ 'dashboard.endDateError': 'Datum konce musí být po datu začátku',
+
+ // Nastavení (Settings)
+ 'settings.title': 'Nastavení',
+ 'settings.subtitle': 'Upravte své osobní nastavení',
+ 'settings.map': 'Mapy',
+ 'settings.mapTemplate': 'Šablona mapy',
+ 'settings.mapTemplatePlaceholder.select': 'Vyberte šablonu...',
+ 'settings.mapDefaultHint': 'Ponechte prázdné pro OpenStreetMap (výchozí)',
+ 'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
+ 'settings.mapHint': 'URL šablony pro mapové dlaždice',
+ 'settings.latitude': 'Zeměpisná šířka',
+ 'settings.longitude': 'Zeměpisná délka',
+ 'settings.saveMap': 'Uložit nastavení mapy',
+ 'settings.apiKeys': 'API klíče',
+ 'settings.mapsKey': 'Google Maps API klíč',
+ 'settings.mapsKeyHint': 'Pro vyhledávání míst. Vyžaduje Places API (New). Získáte na console.cloud.google.com',
+ 'settings.weatherKey': 'OpenWeatherMap API klíč',
+ 'settings.weatherKeyHint': 'Pro předpověď počasí. Zdarma na openweathermap.org/api',
+ 'settings.keyPlaceholder': 'Vložte klíč...',
+ 'settings.configured': 'Nastaveno',
+ 'settings.saveKeys': 'Uložit klíče',
+ 'settings.display': 'Zobrazení',
+ 'settings.colorMode': 'Barevné schéma',
+ 'settings.light': 'Světlé',
+ 'settings.dark': 'Tmavé',
+ 'settings.auto': 'Automatické',
+ 'settings.language': 'Jazyk',
+ 'settings.temperature': 'Jednotky teploty',
+ 'settings.timeFormat': 'Formát času',
+ 'settings.routeCalculation': 'Výpočet trasy',
+ 'settings.blurBookingCodes': 'Skrýt rezervační kódy',
+ 'settings.notifications': 'Oznámení',
+ 'settings.notifyTripInvite': 'Pozvánky na cesty',
+ 'settings.notifyBookingChange': 'Změny rezervací',
+ 'settings.notifyTripReminder': 'Připomínky cest',
+ 'settings.notifyVacayInvite': 'Pozvánky k propojení Vacay',
+ 'settings.notifyPhotosShared': 'Sdílené fotky (Immich)',
+ 'settings.notifyCollabMessage': 'Zprávy v chatu (Collab)',
+ 'settings.notifyPackingTagged': 'Seznam balení: přiřazení',
+ 'settings.notifyWebhook': 'Webhook oznámení',
+ 'settings.on': 'Zapnuto',
+ 'settings.off': 'Vypnuto',
+ 'settings.mcp.title': 'Konfigurace MCP',
+ 'settings.mcp.endpoint': 'MCP endpoint',
+ 'settings.mcp.clientConfig': 'Konfigurace klienta',
+ 'settings.mcp.clientConfigHint': 'Nahraďte API tokenem ze seznamu níže. Cestu k npx může být nutné upravit pro váš systém (např. C:\\PROGRA~1\\nodejs\\npx.cmd ve Windows).',
+ 'settings.mcp.copy': 'Kopírovat',
+ 'settings.mcp.copied': 'Zkopírováno!',
+ 'settings.mcp.apiTokens': 'API tokeny',
+ 'settings.mcp.createToken': 'Vytvořit nový token',
+ 'settings.mcp.noTokens': 'Zatím žádné tokeny. Vytvořte jeden pro připojení MCP klientů.',
+ 'settings.mcp.tokenCreatedAt': 'Vytvořen',
+ 'settings.mcp.tokenUsedAt': 'Použit',
+ 'settings.mcp.deleteTokenTitle': 'Smazat token',
+ 'settings.mcp.deleteTokenMessage': 'Tento token přestane okamžitě fungovat. Všichni MCP klienti, kteří ho používají, ztratí přístup.',
+ 'settings.mcp.modal.createTitle': 'Vytvořit API token',
+ 'settings.mcp.modal.tokenName': 'Název tokenu',
+ 'settings.mcp.modal.tokenNamePlaceholder': 'např. Claude Desktop, Pracovní notebook',
+ 'settings.mcp.modal.creating': 'Vytváření…',
+ 'settings.mcp.modal.create': 'Vytvořit token',
+ 'settings.mcp.modal.createdTitle': 'Token vytvořen',
+ 'settings.mcp.modal.createdWarning': 'Tento token bude zobrazen pouze jednou. Zkopírujte a uložte ho nyní — nelze ho obnovit.',
+ 'settings.mcp.modal.done': 'Hotovo',
+ 'settings.mcp.toast.created': 'Token vytvořen',
+ 'settings.mcp.toast.createError': 'Nepodařilo se vytvořit token',
+ 'settings.mcp.toast.deleted': 'Token smazán',
+ 'settings.mcp.toast.deleteError': 'Nepodařilo se smazat token',
+ 'settings.account': 'Účet',
+ 'settings.username': 'Uživatelské jméno',
+ 'settings.email': 'E-mail',
+ 'settings.role': 'Role',
+ 'settings.roleAdmin': 'Administrátor',
+ 'settings.oidcLinked': 'Propojeno přes',
+ 'settings.changePassword': 'Změnit heslo',
+ 'settings.currentPassword': 'Současné heslo',
+ 'settings.currentPasswordRequired': 'Současné heslo je vyžadováno',
+ 'settings.newPassword': 'Nové heslo',
+ 'settings.confirmPassword': 'Potvrdit nové heslo',
+ 'settings.updatePassword': 'Aktualizovat heslo',
+ 'settings.passwordRequired': 'Zadejte prosím současné i nové heslo',
+ 'settings.passwordTooShort': 'Heslo musí mít alespoň 8 znaků',
+ 'settings.passwordMismatch': 'Hesla se neshodují',
+ 'settings.passwordWeak': 'Heslo musí obsahovat velké a malé písmeno a číslici',
+ 'settings.passwordChanged': 'Heslo bylo úspěšně změněno',
+ 'settings.deleteAccount': 'Smazat účet',
+ 'settings.deleteAccountTitle': 'Smazat váš účet?',
+ 'settings.deleteAccountWarning': 'Váš účet a všechny vaše cesty, místa a soubory budou trvale smazány. Tuto akci nelze vrátit.',
+ 'settings.deleteAccountConfirm': 'Smazat natrvalo',
+ 'settings.deleteBlockedTitle': 'Účet nelze smazat',
+ 'settings.deleteBlockedMessage': 'Jste jediným administrátorem. Před smazáním svého účtu předejte roli administrátora jinému uživateli.',
+ 'settings.roleUser': 'Uživatel',
+ 'settings.saveProfile': 'Uložit profil',
+ 'settings.toast.mapSaved': 'Nastavení map uloženo',
+ 'settings.toast.keysSaved': 'API klíče uloženy',
+ 'settings.toast.displaySaved': 'Nastavení zobrazení uloženo',
+ 'settings.toast.profileSaved': 'Profil byl uložen',
+ 'settings.uploadAvatar': 'Nahrát profilový obrázek',
+ 'settings.removeAvatar': 'Odebrat profilový obrázek',
+ 'settings.avatarUploaded': 'Profilový obrázek byl aktualizován',
+ 'settings.avatarRemoved': 'Profilový obrázek byl odstraněn',
+ 'settings.avatarError': 'Nahrávání se nezdařilo',
+ 'settings.mfa.title': 'Dvoufaktorové ověření (2FA)',
+ 'settings.mfa.description': 'Přidá druhý stupeň zabezpečení při přihlašování e-mailem a heslem. Použijte aplikaci (Google Authenticator, Authy apod.).',
+ 'settings.mfa.requiredByPolicy': 'Správce vyžaduje dvoufázové ověření. Nejdřív níže nastavte aplikaci autentikátoru.',
+ 'settings.mfa.backupTitle': 'Záložní kódy',
+ 'settings.mfa.backupDescription': 'Použijte tyto jednorázové kódy, pokud ztratíte přístup k autentizační aplikaci.',
+ 'settings.mfa.backupWarning': 'Uložte si je hned. Každý kód lze použít pouze jednou.',
+ 'settings.mfa.backupCopy': 'Kopírovat kódy',
+ 'settings.mfa.backupDownload': 'Stáhnout TXT',
+ 'settings.mfa.backupPrint': 'Tisk / PDF',
+ 'settings.mfa.backupCopied': 'Záložní kódy zkopírovány',
+ 'settings.mfa.enabled': '2FA je pro váš účet aktivní.',
+ 'settings.mfa.disabled': '2FA není aktivní.',
+ 'settings.mfa.setup': 'Nastavit autentizační aplikaci',
+ 'settings.mfa.scanQr': 'Naskenujte tento QR kód ve vaší aplikaci nebo zadejte kód ručně.',
+ 'settings.mfa.secretLabel': 'Tajný klíč (pro ruční zadání)',
+ 'settings.mfa.codePlaceholder': '6místný kód',
+ 'settings.mfa.enable': 'Zapnout 2FA',
+ 'settings.mfa.cancelSetup': 'Zrušit',
+ 'settings.mfa.disableTitle': 'Vypnout 2FA',
+ 'settings.mfa.disableHint': 'Zadejte své heslo k účtu a aktuální kód z aplikace.',
+ 'settings.mfa.disable': 'Vypnout 2FA',
+ 'settings.mfa.toastEnabled': 'Dvoufaktorové ověření bylo zapnuto',
+ 'settings.mfa.toastDisabled': 'Dvoufaktorové ověření bylo vypnuto',
+ 'settings.mfa.demoBlocked': 'Není k dispozici v demo režimu',
+ 'admin.smtp.title': 'E-mail a oznámení',
+ 'admin.smtp.hint': 'Konfigurace SMTP pro e-mailová oznámení. Volitelně: Webhook URL pro Discord, Slack apod.',
+ 'admin.smtp.testButton': 'Odeslat testovací e-mail',
+ 'admin.smtp.testSuccess': 'Testovací e-mail byl úspěšně odeslán',
+ 'admin.smtp.testFailed': 'Odeslání testovacího e-mailu se nezdařilo',
+ 'dayplan.icsTooltip': 'Exportovat kalendář (ICS)',
+ 'share.linkTitle': 'Veřejný odkaz',
+ 'share.linkHint': 'Vytvořte odkaz, kterým si může kdokoli prohlédnout tuto cestu bez přihlášení. Pouze pro čtení — úpravy nejsou možné.',
+ 'share.createLink': 'Vytvořit odkaz',
+ 'share.deleteLink': 'Smazat odkaz',
+ 'share.createError': 'Nepodařilo se vytvořit odkaz',
+ 'common.copy': 'Kopírovat',
+ 'common.copied': 'Zkopírováno',
+ 'share.permMap': 'Mapa a plán',
+ 'share.permBookings': 'Rezervace',
+ 'share.permPacking': 'Balení',
+ 'shared.expired': 'Odkaz vypršel nebo je neplatný',
+ 'shared.expiredHint': 'Tento sdílený odkaz na cestu již není aktivní.',
+ 'shared.readOnly': 'Sdílené zobrazení – pouze pro čtení',
+ 'shared.tabPlan': 'Plán',
+ 'shared.tabBookings': 'Rezervace',
+ 'shared.tabPacking': 'Balení',
+ 'shared.tabBudget': 'Rozpočet',
+ 'shared.tabChat': 'Chat',
+ 'shared.days': 'dní',
+ 'shared.places': 'míst',
+ 'shared.other': 'Ostatní',
+ 'shared.totalBudget': 'Celkový rozpočet',
+ 'shared.messages': 'zpráv',
+ 'shared.sharedVia': 'Sdíleno přes',
+ 'shared.confirmed': 'Potvrzeno',
+ 'shared.pending': 'Čeká na potvrzení',
+ 'share.permBudget': 'Rozpočet',
+ 'share.permCollab': 'Chat',
+
+ // Přihlášení (Login)
+ 'login.error': 'Přihlášení se nezdařilo. Zkontrolujte prosím své údaje.',
+ 'login.tagline': 'Vaše cesty.\nVáš plán.',
+ 'login.description': 'Plánujte cesty společně s interaktivními mapami, rozpočty a synchronizací v reálném čase.',
+ 'login.features.maps': 'Interaktivní mapy',
+ 'login.features.mapsDesc': 'Google Places, trasy a shlukování bodů',
+ 'login.features.realtime': 'Synchronizace v reálném čase',
+ 'login.features.realtimeDesc': 'Plánujte společně přes WebSocket',
+ 'login.features.budget': 'Sledování rozpočtu',
+ 'login.features.budgetDesc': 'Kategorie, grafy a náklady na osobu',
+ 'login.features.collab': 'Spolupráce',
+ 'login.features.collabDesc': 'Více uživatelů se sdílenými cestami',
+ 'login.features.packing': 'Seznamy věcí',
+ 'login.features.packingDesc': 'Kategorie, pokrok v balení a návrhy',
+ 'login.features.bookings': 'Rezervace',
+ 'login.features.bookingsDesc': 'Lety, hotely, restaurace a další',
+ 'login.features.files': 'Dokumenty',
+ 'login.features.filesDesc': 'Nahrávejte a spravujte dokumenty',
+ 'login.features.routes': 'Chytré trasy',
+ 'login.features.routesDesc': 'Automatická optimalizace a export do Google Maps',
+ 'login.selfHosted': 'Self-hosted · Open Source · Vaše data zůstávají u vás',
+ 'login.title': 'Přihlásit se',
+ 'login.subtitle': 'Vítejte zpět',
+ 'login.signingIn': 'Přihlašování…',
+ 'login.signIn': 'Přihlásit se',
+ 'login.createAdmin': 'Vytvořit účet administrátora',
+ 'login.createAdminHint': 'Nastavte první administrátorský účet pro TREK.',
+ 'login.createAccount': 'Vytvořit účet',
+ 'login.createAccountHint': 'Zaregistrujte si nový účet.',
+ 'login.creating': 'Vytváření…',
+ 'login.noAccount': 'Nemáte účet?',
+ 'login.hasAccount': 'Již máte účet?',
+ 'login.register': 'Registrovat se',
+ 'login.emailPlaceholder': 'vas@email.cz',
+ 'login.username': 'Uživatelské jméno',
+ 'login.oidc.registrationDisabled': 'Registrace je zakázána. Kontaktujte svého administrátora.',
+ 'login.oidc.noEmail': 'Od poskytovatele nebyl přijat žádný e-mail.',
+ 'login.oidc.tokenFailed': 'Ověření se nezdařilo.',
+ 'login.oidc.invalidState': 'Neplatná relace. Zkuste to prosím znovu.',
+ 'login.demoFailed': 'Přihlášení do dema se nezdařilo',
+ 'login.oidcSignIn': 'Přihlásit se přes {name}',
+ 'login.oidcOnly': 'Ověřování heslem je zakázáno. Přihlaste se prosím přes SSO poskytovatele.',
+ 'login.demoHint': 'Vyzkoušejte demo – registrace není nutná',
+ 'login.mfaTitle': 'Dvoufaktorové ověření',
+ 'login.mfaSubtitle': 'Zadejte 6místný kód z vaší autentizační aplikace.',
+ 'login.mfaCodeLabel': 'Ověřovací kód',
+ 'login.mfaCodeRequired': 'Zadejte kód z aplikace.',
+ 'login.mfaHint': 'Otevřete Google Authenticator, Authy nebo jinou TOTP aplikaci.',
+ 'login.mfaBack': '← Zpět k přihlášení',
+ 'login.mfaVerify': 'Ověřit',
+
+ // Registrace (Register)
+ 'register.passwordMismatch': 'Hesla se neshodují',
+ 'register.passwordTooShort': 'Heslo musí mít alespoň 6 znaků',
+ 'register.failed': 'Registrace se nezdařila',
+ 'register.getStarted': 'Začínáme',
+ 'register.subtitle': 'Vytvořte si účet a začněte plánovat svou vysněnou cestu.',
+ 'register.feature1': 'Neomezené plány cest',
+ 'register.feature2': 'Zobrazení na interaktivní mapě',
+ 'register.feature3': 'Správa míst a kategorií',
+ 'register.feature4': 'Sledování rezervací',
+ 'register.feature5': 'Vytváření seznamů věcí',
+ 'register.feature6': 'Ukládání fotek a souborů',
+ 'register.createAccount': 'Vytvořit účet',
+ 'register.startPlanning': 'Začít plánovat',
+ 'register.minChars': 'Min. 6 znaků',
+ 'register.confirmPassword': 'Potvrdit heslo',
+ 'register.repeatPassword': 'Heslo znovu',
+ 'register.registering': 'Registrace...',
+ 'register.register': 'Registrovat se',
+ 'register.hasAccount': 'Již máte účet?',
+ 'register.signIn': 'Přihlásit se',
+
+ // Administrace (Admin)
+ 'admin.title': 'Administrace',
+ 'admin.subtitle': 'Správa uživatelů a systémová nastavení',
+ 'admin.tabs.users': 'Uživatelé',
+ 'admin.tabs.categories': 'Kategorie',
+ 'admin.tabs.backup': 'Zálohování',
+ 'admin.stats.users': 'Uživatelé',
+ 'admin.stats.trips': 'Cesty',
+ 'admin.stats.places': 'Místa',
+ 'admin.stats.photos': 'Fotky',
+ 'admin.stats.files': 'Soubory',
+ 'admin.table.user': 'Uživatel',
+ 'admin.table.email': 'E-mail',
+ 'admin.table.role': 'Role',
+ 'admin.table.created': 'Vytvořeno',
+ 'admin.table.lastLogin': 'Poslední přihlášení',
+ 'admin.table.actions': 'Akce',
+ 'admin.you': '(Vy)',
+ 'admin.editUser': 'Upravit uživatele',
+ 'admin.newPassword': 'Nové heslo',
+ 'admin.newPasswordHint': 'Ponechte prázdné pro zachování současného hesla',
+ 'admin.deleteUser': 'Smazat uživatele „{name}“? Všechny jeho cesty budou trvale smazány.',
+ 'admin.deleteUserTitle': 'Smazat uživatele',
+ 'admin.newPasswordPlaceholder': 'Zadejte nové heslo…',
+ 'admin.toast.loadError': 'Nepodařilo se načíst data administrace',
+ 'admin.toast.userUpdated': 'Uživatel byl aktualizován',
+ 'admin.toast.updateError': 'Aktualizace se nezdařila',
+ 'admin.toast.userDeleted': 'Uživatel byl smazán',
+ 'admin.toast.deleteError': 'Smazání se nezdařilo',
+ 'admin.toast.cannotDeleteSelf': 'Nemůžete smazat svůj vlastní účet',
+ 'admin.toast.userCreated': 'Uživatel byl vytvořen',
+ 'admin.toast.createError': 'Nepodařilo se vytvořit uživatele',
+ 'admin.toast.fieldsRequired': 'Uživatelské jméno, e-mail a heslo jsou povinné',
+ 'admin.createUser': 'Vytvořit uživatele',
+ 'admin.invite.title': 'Pozvánky',
+ 'admin.invite.subtitle': 'Vytvářejte jednorázové registrační odkazy',
+ 'admin.invite.create': 'Vytvořit odkaz',
+ 'admin.invite.createAndCopy': 'Vytvořit a zkopírovat',
+ 'admin.invite.empty': 'Zatím nebyly vytvořeny žádné pozvánky',
+ 'admin.invite.maxUses': 'Max. použití',
+ 'admin.invite.expiry': 'Vyprší za',
+ 'admin.invite.uses': 'použito',
+ 'admin.invite.expiresAt': 'vyprší',
+ 'admin.invite.createdBy': 'vytvořil',
+ 'admin.invite.active': 'Aktivní',
+ 'admin.invite.expired': 'Expirované',
+ 'admin.invite.usedUp': 'Využito',
+ 'admin.invite.copied': 'Odkaz byl zkopírován do schránky',
+ 'admin.invite.copyLink': 'Kopírovat odkaz',
+ 'admin.invite.deleted': 'Pozvánka smazána',
+ 'admin.invite.createError': 'Nepodařilo se vytvořit pozvánku',
+ 'admin.invite.deleteError': 'Nepodařilo se smazat pozvánku',
+ 'admin.tabs.settings': 'Nastavení',
+ 'admin.allowRegistration': 'Povolit registraci',
+ 'admin.allowRegistrationHint': 'Noví uživatelé se mohou sami registrovat',
+ 'admin.requireMfa': 'Vyžadovat dvoufázové ověření (2FA)',
+ 'admin.requireMfaHint': 'Uživatelé bez 2FA musí dokončit nastavení v Nastavení před použitím aplikace.',
+ 'admin.apiKeys': 'API klíče',
+ 'admin.apiKeysHint': 'Volitelné. Povoluje rozšířená data o místech (fotky, počasí).',
+ 'admin.mapsKey': 'Google Maps API klíč',
+ 'admin.mapsKeyHint': 'Povinné pro hledání míst. Získáte na console.cloud.google.com',
+ 'admin.mapsKeyHintLong': 'Bez API klíče se pro hledání používá OpenStreetMap. S Google klíčem lze načítat fotky, hodnocení a otevírací dobu.',
+ 'admin.recommended': 'Doporučeno',
+ 'admin.weatherKey': 'OpenWeatherMap API klíč',
+ 'admin.weatherKeyHint': 'Pro data o počasí. Zdarma na openweathermap.org',
+ 'admin.validateKey': 'Testovat',
+ 'admin.keyValid': 'Připojeno',
+ 'admin.keyInvalid': 'Neplatný',
+ 'admin.keySaved': 'API klíče byly uloženy',
+ 'admin.oidcTitle': 'Jednotné přihlášení (OIDC)',
+ 'admin.oidcSubtitle': 'Povolit přihlášení přes externí poskytovatele (Google, Apple, Authentik, Keycloak).',
+ 'admin.oidcDisplayName': 'Zobrazované jméno',
+ 'admin.oidcIssuer': 'URL vydavatele (Issuer)',
+ 'admin.oidcIssuerHint': 'OpenID Connect Issuer URL, např. https://accounts.google.com',
+ 'admin.oidcSaved': 'Konfigurace OIDC uložena',
+ 'admin.oidcOnlyMode': 'Zakázat ověřování heslem',
+ 'admin.oidcOnlyModeHint': 'Pokud je zapnuto, je povolen pouze SSO login. Registrace i přihlášení heslem budou zablokovány.',
+
+ // Typy souborů (File Types)
+ 'admin.fileTypes': 'Povolené typy souborů',
+ 'admin.fileTypesHint': 'Nastavte, které typy souborů mohou uživatelé nahrávat.',
+ 'admin.fileTypesFormat': 'Přípony oddělené čárkou (např. jpg,png,pdf,doc). Použijte * pro všechny typy.',
+ 'admin.fileTypesSaved': 'Nastavení souborů uloženo',
+
+ // Šablony balení (Packing Templates)
+ 'admin.bagTracking.title': 'Sledování zavazadel',
+ 'admin.bagTracking.subtitle': 'Povolit váhu a přiřazení k zavazadlům u položek balení',
+ 'admin.tabs.config': 'Konfigurace',
+ 'admin.tabs.templates': 'Šablony seznamů',
+ 'admin.packingTemplates.title': 'Šablony pro balení',
+ 'admin.packingTemplates.subtitle': 'Vytvářejte opakovaně použitelné seznamy pro své cesty',
+ 'admin.packingTemplates.create': 'Nová šablona',
+ 'admin.packingTemplates.namePlaceholder': 'Název šablony (např. Dovolená u moře)',
+ 'admin.packingTemplates.empty': 'Zatím nejsou vytvořeny žádné šablony',
+ 'admin.packingTemplates.items': 'položek',
+ 'admin.packingTemplates.categories': 'kategorií',
+ 'admin.packingTemplates.itemName': 'Název položky',
+ 'admin.packingTemplates.itemCategory': 'Kategorie',
+ 'admin.packingTemplates.categoryName': 'Název kategorie (např. Oblečení)',
+ 'admin.packingTemplates.addCategory': 'Přidat kategorii',
+ 'admin.packingTemplates.created': 'Šablona vytvořena',
+ 'admin.packingTemplates.deleted': 'Šablona smazána',
+ 'admin.packingTemplates.loadError': 'Nepodařilo se načíst šablony',
+ 'admin.packingTemplates.createError': 'Nepodařilo se vytvořit šablonu',
+ 'admin.packingTemplates.deleteError': 'Nepodařilo se smazat šablonu',
+ 'admin.packingTemplates.saveError': 'Uložení se nezdařilo',
+
+ // Doplňky (Addons)
+ 'admin.tabs.addons': 'Doplňky',
+ 'admin.addons.title': 'Doplňky',
+ 'admin.addons.subtitle': 'Zapněte nebo vypněte funkce a přizpůsobte si TREK.',
+ 'admin.addons.catalog.memories.name': 'Fotky (Immich)',
+ 'admin.addons.catalog.memories.description': 'Sdílejte cestovní fotky přes vaši instanci Immich',
+ 'admin.addons.catalog.packing.name': 'Balení',
+ 'admin.addons.catalog.packing.description': 'Seznamy věcí pro přípravu na cestu',
+ 'admin.addons.catalog.budget.name': 'Rozpočet',
+ 'admin.addons.catalog.budget.description': 'Sledování výdajů a plánování rozpočtu cesty',
+ 'admin.addons.catalog.documents.name': 'Dokumenty',
+ 'admin.addons.catalog.documents.description': 'Ukládání a správa cestovních dokladů',
+ 'admin.addons.catalog.vacay.name': 'Dovolená (Vacay)',
+ 'admin.addons.catalog.vacay.description': 'Osobní plánovač dovolené s kalendářem',
+ 'admin.addons.catalog.atlas.name': 'Atlas',
+ 'admin.addons.catalog.atlas.description': 'Mapa světa s navštívenými zeměmi a statistikami',
+ 'admin.addons.catalog.collab.name': 'Spolupráce',
+ 'admin.addons.catalog.collab.description': 'Poznámky v reálném čase, hlasování a chat pro plánování',
+ 'admin.addons.enabled': 'Povoleno',
+ 'admin.addons.disabled': 'Zakázáno',
+ 'admin.addons.type.trip': 'Cesta',
+ 'admin.addons.type.global': 'Globální',
+ 'admin.addons.type.integration': 'Integrace',
+ 'admin.addons.tripHint': 'Dostupné jako karta v rámci každé cesty',
+ 'admin.addons.globalHint': 'Dostupné jako samostatná sekce v hlavní navigaci',
+ 'admin.addons.integrationHint': 'Backendové služby a API integrace bez vlastní stránky',
+ 'admin.addons.toast.updated': 'Doplněk byl aktualizován',
+ 'admin.addons.toast.error': 'Aktualizace doplňku se nezdařila',
+ 'admin.addons.noAddons': 'Žádné doplňky nejsou k dispozici',
+ 'admin.addons.catalog.memories.name': 'Fotky (Immich)',
+ 'admin.addons.catalog.memories.description': 'Sdílejte cestovní fotky přes vaši instanci Immich',
+ 'admin.addons.catalog.mcp.name': 'MCP',
+ 'admin.addons.catalog.mcp.description': 'Model Context Protocol pro integraci AI asistentů',
+ 'admin.addons.subtitleBefore': 'Zapněte nebo vypněte funkce a přizpůsobte si ',
+ 'admin.addons.subtitleAfter': '.',
+
+ 'admin.tabs.audit': 'Auditní protokol',
+
+ 'admin.audit.subtitle': 'Bezpečnostní a administrátorské události (zálohy, uživatelé, 2FA, nastavení).',
+ 'admin.audit.empty': 'Zatím žádné záznamy auditu.',
+ 'admin.audit.refresh': 'Obnovit',
+ 'admin.audit.loadMore': 'Načíst další',
+ 'admin.audit.showing': '{count} načteno · {total} celkem',
+ 'admin.audit.col.time': 'Čas',
+ 'admin.audit.col.user': 'Uživatel',
+ 'admin.audit.col.action': 'Akce',
+ 'admin.audit.col.resource': 'Zdroj',
+ 'admin.audit.col.ip': 'IP',
+ 'admin.audit.col.details': 'Detaily',
+
+ // MCP Tokens
+ 'admin.tabs.mcpTokens': 'MCP tokeny',
+ 'admin.mcpTokens.title': 'MCP tokeny',
+ 'admin.mcpTokens.subtitle': 'Správa API tokenů všech uživatelů',
+ 'admin.mcpTokens.owner': 'Vlastník',
+ 'admin.mcpTokens.tokenName': 'Název tokenu',
+ 'admin.mcpTokens.created': 'Vytvořen',
+ 'admin.mcpTokens.lastUsed': 'Naposledy použit',
+ 'admin.mcpTokens.never': 'Nikdy',
+ 'admin.mcpTokens.empty': 'Zatím nebyly vytvořeny žádné MCP tokeny',
+ 'admin.mcpTokens.deleteTitle': 'Smazat token',
+ 'admin.mcpTokens.deleteMessage': 'Tento token bude okamžitě zneplatněn. Uživatel ztratí MCP přístup přes tento token.',
+ 'admin.mcpTokens.deleteSuccess': 'Token smazán',
+ 'admin.mcpTokens.deleteError': 'Nepodařilo se smazat token',
+ 'admin.mcpTokens.loadError': 'Nepodařilo se načíst tokeny',
+
+ // GitHub
+ 'admin.tabs.github': 'GitHub',
+ 'admin.github.title': 'Historie verzí',
+ 'admin.github.subtitle': 'Nejnovější aktualizace z {repo}',
+ 'admin.github.latest': 'Nejnovější',
+ 'admin.github.prerelease': 'Předběžná verze',
+ 'admin.github.showDetails': 'Zobrazit podrobnosti',
+ 'admin.github.hideDetails': 'Skrýt podrobnosti',
+ 'admin.github.loadMore': 'Načíst další',
+ 'admin.github.loading': 'Načítání...',
+ 'admin.github.error': 'Nepodařilo se načíst verze',
+ 'admin.github.by': 'od',
+ 'admin.github.support': 'Pomáhá udržovat vývoj TREK',
+
+ // Počasí (Weather)
+ 'admin.weather.title': 'Data o počasí',
+ 'admin.weather.badge': 'Od 24. března 2026',
+ 'admin.weather.description': 'TREK používá Open-Meteo jako zdroj dat. Je to bezplatná open-source služba – není vyžadován API klíč.',
+ 'admin.weather.forecast': 'Předpověď na 16 dní',
+ 'admin.weather.forecastDesc': 'Dříve 5 dní (OpenWeatherMap)',
+ 'admin.weather.climate': 'Historická klimatická data',
+ 'admin.weather.climateDesc': 'Průměry za posledních 85 let pro dny mimo 16denní předpověď',
+ 'admin.weather.requests': '10 000 požadavků denně',
+ 'admin.weather.requestsDesc': 'Zdarma, bez nutnosti klíče',
+ 'admin.weather.locationHint': 'Počasí se určuje podle prvního místa se souřadnicemi v daném dni.',
+
+ // Aktualizace (Updates)
+ 'admin.update.available': 'Dostupná aktualizace',
+ 'admin.update.text': 'TREK {version} je k dispozici. Aktuálně používáte verzi {current}.',
+ 'admin.update.button': 'Zobrazit na GitHubu',
+ 'admin.update.install': 'Instalovat aktualizaci',
+ 'admin.update.confirmTitle': 'Instalovat aktualizaci?',
+ 'admin.update.confirmText': 'TREK bude aktualizován z verze {current} na {version}. Server se poté automaticky restartuje.',
+ 'admin.update.dataInfo': 'Všechna vaše data (cesty, uživatelé, API klíče, soubory) budou zachována.',
+ 'admin.update.warning': 'Aplikace bude během restartu krátce nedostupná.',
+ 'admin.update.confirm': 'Aktualizovat nyní',
+ 'admin.update.installing': 'Aktualizace probíhá…',
+ 'admin.update.success': 'Aktualizace byla nainstalována! Server se restartuje…',
+ 'admin.update.failed': 'Aktualizace se nezdařila',
+ 'admin.update.backupHint': 'Před aktualizací doporučujeme vytvořit zálohu.',
+ 'admin.update.backupLink': 'Přejít na zálohování',
+ 'admin.update.howTo': 'Jak aktualizovat',
+ 'admin.update.dockerText': 'Váš TREK běží v Dockeru. Pro aktualizaci na verzi {version} spusťte na svém serveru tyto příkazy:',
+ 'admin.update.reloadHint': 'Prosím obnovte stránku za několik sekund.',
+
+ // Vacay doplněk
+ 'vacay.subtitle': 'Plánování a správa dovolené',
+ 'vacay.settings': 'Nastavení',
+ 'vacay.year': 'Rok',
+ 'vacay.addYear': 'Přidat rok',
+ 'vacay.removeYear': 'Odebrat rok',
+ 'vacay.removeYearConfirm': 'Odebrat rok {year}?',
+ 'vacay.removeYearHint': 'Všechny záznamy o dovolené a firemní svátky pro tento rok budou trvale smazány.',
+ 'vacay.remove': 'Odebrat',
+ 'vacay.persons': 'Osoby',
+ 'vacay.noPersons': 'Žádné osoby nebyly přidány',
+ 'vacay.addPerson': 'Přidat osobu',
+ 'vacay.editPerson': 'Upravit osobu',
+ 'vacay.removePerson': 'Odebrat osobu',
+ 'vacay.removePersonConfirm': 'Odebrat osobu {name}?',
+ 'vacay.removePersonHint': 'Všechny záznamy dovolené pro tuto osobu budou trvale smazány.',
+ 'vacay.personName': 'Jméno',
+ 'vacay.personNamePlaceholder': 'Zadejte jméno',
+ 'vacay.color': 'Barva',
+ 'vacay.add': 'Přidat',
+ 'vacay.legend': 'Legenda',
+ 'vacay.publicHoliday': 'Státní svátek',
+ 'vacay.companyHoliday': 'Firemní volno',
+ 'vacay.weekend': 'Víkend',
+ 'vacay.modeVacation': 'Dovolená',
+ 'vacay.modeCompany': 'Firemní volno',
+ 'vacay.entitlement': 'Nárok',
+ 'vacay.entitlementDays': 'Dní',
+ 'vacay.used': 'Vyčerpáno',
+ 'vacay.remaining': 'Zbývá',
+ 'vacay.carriedOver': 'z roku {year}',
+ 'vacay.blockWeekends': 'Blokovat víkendy',
+ 'vacay.blockWeekendsHint': 'Zamezit zadávání dovolené na víkendové dny',
+ 'vacay.mon': 'Po',
+ 'vacay.tue': 'Út',
+ 'vacay.wed': 'St',
+ 'vacay.thu': 'Čt',
+ 'vacay.fri': 'Pá',
+ 'vacay.sat': 'So',
+ 'vacay.sun': 'Ne',
+ 'vacay.weekendDays': 'Víkendové dny',
+ 'vacay.publicHolidays': 'Státní svátky',
+ 'vacay.publicHolidaysHint': 'Zobrazit státní svátky v kalendáři',
+ 'vacay.selectCountry': 'Vyberte zemi',
+ 'vacay.selectRegion': 'Vyberte region (volitelné)',
+ 'vacay.addCalendar': 'Přidat kalendář',
+ 'vacay.calendarLabel': 'Popisek (volitelné)',
+ 'vacay.calendarColor': 'Barva',
+ 'vacay.noCalendars': 'Zatím nebyly přidány žádné svátkové kalendáře',
+ 'vacay.companyHolidays': 'Firemní volno',
+ 'vacay.companyHolidaysHint': 'Povolit označování dnů celofiremního volna',
+ 'vacay.companyHolidaysNoDeduct': 'Firemní volno se nezapočítává do nároku na dovolenou.',
+ 'vacay.carryOver': 'Převod dovolené',
+ 'vacay.carryOverHint': 'Automaticky převádět zbývající dny do dalšího roku',
+ 'vacay.sharing': 'Sdílení',
+ 'vacay.sharingHint': 'Sdílejte svůj plán dovolené s ostatními uživateli TREK',
+ 'vacay.owner': 'Vlastník',
+ 'vacay.shareEmailPlaceholder': 'E-mail uživatele TREK',
+ 'vacay.shareSuccess': 'Plán byl úspěšně sdílen',
+ 'vacay.shareError': 'Nepodařilo se sdílet plán',
+ 'vacay.dissolve': 'Zrušit propojení',
+ 'vacay.dissolveHint': 'Znovu oddělit kalendáře. Vaše záznamy zůstanou zachovány.',
+ 'vacay.dissolveAction': 'Oddělit',
+ 'vacay.dissolved': 'Kalendáře byly odděleny',
+ 'vacay.fusedWith': 'Propojeno s',
+ 'vacay.you': 'vy',
+ 'vacay.noData': 'Žádná data',
+ 'vacay.changeColor': 'Změnit barvu',
+ 'vacay.inviteUser': 'Pozvat uživatele',
+ 'vacay.inviteHint': 'Pozvěte jiného uživatele TREK ke sdílení společného kalendáře dovolených.',
+ 'vacay.selectUser': 'Vyberte uživatele',
+ 'vacay.sendInvite': 'Odeslat pozvánku',
+ 'vacay.inviteSent': 'Pozvánka odeslána',
+ 'vacay.inviteError': 'Nepodařilo se odeslat pozvánku',
+ 'vacay.pending': 'čeká na vyřízení',
+ 'vacay.noUsersAvailable': 'Žádní uživatelé nejsou k dispozici',
+ 'vacay.accept': 'Přijmout',
+ 'vacay.decline': 'Odmítnout',
+ 'vacay.acceptFusion': 'Přijmout a propojit',
+ 'vacay.inviteTitle': 'Žádost o propojení',
+ 'vacay.inviteWantsToFuse': 'vás zve ke sdílení kalendáře dovolených.',
+ 'vacay.fuseInfo1': 'Oba uvidíte všechny záznamy v jednom sdíleném kalendáři.',
+ 'vacay.fuseInfo2': 'Obě strany mohou vytvářet a upravovat záznamy tomu druhému.',
+ 'vacay.fuseInfo3': 'Obě strany mohou měnit nároky na dovolenou.',
+ 'vacay.fuseInfo4': 'Nastavení (svátky, firemní volno) jsou sdílená.',
+ 'vacay.fuseInfo5': 'Propojení lze kdykoli zrušit bez ztráty dat.',
+
+ // Atlas doplněk
+ 'atlas.subtitle': 'Vaše stopa ve světě',
+ 'atlas.countries': 'Země',
+ 'atlas.trips': 'Cesty',
+ 'atlas.places': 'Místa',
+ 'atlas.unmark': 'Odebrat',
+ 'atlas.confirmMark': 'Označit tuto zemi jako navštívenou?',
+ 'atlas.confirmUnmark': 'Odebrat tuto zemi ze seznamu navštívených?',
+ 'atlas.markVisited': 'Označit jako navštívené',
+ 'atlas.markVisitedHint': 'Přidat tuto zemi do seznamu navštívených',
+ 'atlas.addToBucket': 'Přidat do seznamu přání (Bucket list)',
+ 'atlas.addPoi': 'Přidat místo',
+ 'atlas.bucketNamePlaceholder': 'Název (země, město, místo...)',
+ 'atlas.month': 'Měsíc',
+ 'atlas.addToBucketHint': 'Uložit jako místo, které chcete navštívit',
+ 'atlas.bucketWhen': 'Kdy plánujete návštěvu?',
+ 'atlas.statsTab': 'Statistiky',
+ 'atlas.bucketTab': 'Bucket List',
+ 'atlas.addBucket': 'Přidat na Bucket List',
+ 'atlas.bucketNamePlaceholder': 'Místo nebo destinace...',
+ 'atlas.bucketNotesPlaceholder': 'Poznámky (volitelné)',
+ 'atlas.bucketEmpty': 'Váš seznam přání je prázdný',
+ 'atlas.bucketEmptyHint': 'Přidejte místa, která sníte navštívit',
+ 'atlas.days': 'Dní',
+ 'atlas.visitedCountries': 'Navštívené země',
+ 'atlas.cities': 'Města',
+ 'atlas.noData': 'Zatím žádná cestovatelská data',
+ 'atlas.noDataHint': 'Vytvořte cestu a přidejte místa, abyste viděli svou mapu světa',
+ 'atlas.lastTrip': 'Poslední cesta',
+ 'atlas.nextTrip': 'Další cesta',
+ 'atlas.daysLeft': 'dní zbývá',
+ 'atlas.streak': 'Série',
+ 'atlas.year': 'rok',
+ 'atlas.years': 'roky/let',
+ 'atlas.yearInRow': 'rok v řadě',
+ 'atlas.yearsInRow': 'let v řadě',
+ 'atlas.tripIn': 'cesta v roce',
+ 'atlas.tripsIn': 'cest v roce',
+ 'atlas.since': 'od',
+ 'atlas.europe': 'Evropa',
+ 'atlas.asia': 'Asie',
+ 'atlas.northAmerica': 'S. Amerika',
+ 'atlas.southAmerica': 'J. Amerika',
+ 'atlas.africa': 'Afrika',
+ 'atlas.oceania': 'Oceánie',
+ 'atlas.other': 'Ostatní',
+ 'atlas.firstVisit': 'První cesta',
+ 'atlas.lastVisitLabel': 'Poslední cesta',
+ 'atlas.tripSingular': 'Cesta',
+ 'atlas.tripPlural': 'Cesty',
+ 'atlas.placeVisited': 'Navštívené místo',
+ 'atlas.placesVisited': 'Navštívená místa',
+
+ // Plánovač cesty (Trip Planner)
+ 'trip.tabs.plan': 'Plán',
+ 'trip.tabs.reservations': 'Rezervace',
+ 'trip.tabs.reservationsShort': 'Rez.',
+ 'trip.tabs.packing': 'Seznam věcí',
+ 'trip.tabs.packingShort': 'Balení',
+ 'trip.tabs.budget': 'Rozpočet',
+ 'trip.tabs.files': 'Soubory',
+ 'trip.loading': 'Načítání cesty...',
+ 'trip.mobilePlan': 'Plán',
+ 'trip.mobilePlaces': 'Místa',
+ 'trip.toast.placeUpdated': 'Místo bylo aktualizováno',
+ 'trip.toast.placeAdded': 'Místo bylo přidáno',
+ 'trip.toast.placeDeleted': 'Místo bylo smazáno',
+ 'trip.toast.selectDay': 'Prosím nejdříve vyberte den',
+ 'trip.toast.assignedToDay': 'Místo bylo přiřazeno ke dni',
+ 'trip.toast.reorderError': 'Nepodařilo se změnit pořadí',
+ 'trip.toast.reservationUpdated': 'Rezervace aktualizována',
+ 'trip.toast.reservationAdded': 'Rezervace přidána',
+ 'trip.toast.deleted': 'Smazáno',
+ 'trip.confirm.deletePlace': 'Opravdu chcete toto místo smazat?',
+
+ // Denní plán (Day Plan)
+ 'dayplan.emptyDay': 'Na tento den nejsou naplánována žádná místa',
+ 'dayplan.addNote': 'Přidat poznámku',
+ 'dayplan.editNote': 'Upravit poznámku',
+ 'dayplan.noteAdd': 'Přidat poznámku',
+ 'dayplan.noteEdit': 'Upravit poznámku',
+ 'dayplan.noteTitle': 'Poznámka',
+ 'dayplan.noteSubtitle': 'Poznámka ke dni',
+ 'dayplan.totalCost': 'Celkové náklady',
+ 'dayplan.days': 'Dny',
+ 'dayplan.dayN': 'Den {n}',
+ 'dayplan.calculating': 'Počítání...',
+ 'dayplan.route': 'Trasa',
+ 'dayplan.optimize': 'Optimalizovat',
+ 'dayplan.optimized': 'Trasa optimalizována',
+ 'dayplan.routeError': 'Nepodařilo se vypočítat trasu',
+ 'dayplan.toast.needTwoPlaces': 'Pro optimalizaci trasy jsou potřeba alespoň dvě místa',
+ 'dayplan.toast.routeOptimized': 'Trasa byla optimalizována',
+ 'dayplan.toast.noGeoPlaces': 'Nebyla nalezena žádná místa se souřadnicemi pro výpočet trasy',
+ 'dayplan.confirmed': 'Potvrzeno',
+ 'dayplan.pendingRes': 'Čeká na potvrzení',
+ 'dayplan.pdf': 'PDF',
+ 'dayplan.pdfTooltip': 'Exportovat denní plán do PDF',
+ 'dayplan.pdfError': 'Export do PDF se nezdařil',
+ 'dayplan.cannotReorderTransport': 'Rezervace s pevným časem nelze přeuspořádat',
+ 'dayplan.confirmRemoveTimeTitle': 'Odebrat čas?',
+ 'dayplan.confirmRemoveTimeBody': 'Toto místo má pevný čas ({time}). Přesunutím se čas odebere a povolí se volné řazení.',
+ 'dayplan.confirmRemoveTimeAction': 'Odebrat čas a přesunout',
+ 'dayplan.cannotDropOnTimed': 'Položky nelze umístit mezi záznamy s pevným časem',
+ 'dayplan.cannotBreakChronology': 'Toto by porušilo chronologické pořadí naplánovaných položek a rezervací',
+
+ // Boční panel míst (Places Sidebar)
+ 'places.addPlace': 'Přidat místo/aktivitu',
+ 'places.importGpx': 'Importovat GPX',
+ 'places.gpxImported': '{count} míst importováno z GPX',
+ 'places.urlResolved': 'Místo importováno z URL',
+ 'places.gpxError': 'Import GPX se nezdařil',
+ 'places.assignToDay': 'Přidat do kterého dne?',
+ 'places.all': 'Vše',
+ 'places.unplanned': 'Nezařazené',
+ 'places.search': 'Hledat místa...',
+ 'places.allCategories': 'Všechny kategorie',
+ 'places.categoriesSelected': 'kategorií',
+ 'places.clearFilter': 'Vymazat filtr',
+ 'places.count': '{count} míst',
+ 'places.countSingular': '1 místo',
+ 'places.allPlanned': 'Všechna místa jsou naplánována',
+ 'places.noneFound': 'Žádná místa nebyla nalezena',
+ 'places.editPlace': 'Upravit místo',
+ 'places.formName': 'Název',
+ 'places.formNamePlaceholder': 'např. Eiffelova věž',
+ 'places.formDescription': 'Popis',
+ 'places.formDescriptionPlaceholder': 'Krátký popis...',
+ 'places.formAddress': 'Adresa',
+ 'places.formAddressPlaceholder': 'Ulice, město, země',
+ 'places.formLat': 'Zeměpisná šířka',
+ 'places.formLng': 'Zeměpisná délka',
+ 'places.formCategory': 'Kategorie',
+ 'places.noCategory': 'Bez kategorie',
+ 'places.categoryNamePlaceholder': 'Název kategorie',
+ 'places.formTime': 'Čas',
+ 'places.startTime': 'Od',
+ 'places.endTime': 'Do',
+ 'places.endTimeBeforeStart': 'Čas konce je před časem začátku',
+ 'places.timeCollision': 'Časový překryv s:',
+ 'places.formWebsite': 'Webové stránky',
+ 'places.formNotesPlaceholder': 'Osobní poznámky...',
+ 'places.formReservation': 'Rezervace',
+ 'places.reservationNotesPlaceholder': 'Poznámky k rezervaci, potvrzovací kód...',
+ 'places.mapsSearchPlaceholder': 'Hledat místa...',
+ 'places.mapsSearchError': 'Hledání místa se nezdařilo.',
+ 'places.osmHint': 'Používáte hledání přes OpenStreetMap (bez fotek a hodnocení). Pro plné detaily přidejte Google API klíč v nastavení.',
+ 'places.osmActive': 'Hledání přes OpenStreetMap.',
+ 'places.categoryCreateError': 'Nepodařilo se vytvořit kategorii',
+ 'places.nameRequired': 'Prosím zadejte název',
+ 'places.saveError': 'Uložení se nezdařilo',
+
+ // Inspektor míst (Place Inspector)
+ 'inspector.opened': 'Otevřeno',
+ 'inspector.closed': 'Zavřeno',
+ 'inspector.openingHours': 'Otevírací doba',
+ 'inspector.showHours': 'Zobrazit otevírací dobu',
+ 'inspector.files': 'Soubory',
+ 'inspector.filesCount': '{count} souborů',
+ 'inspector.removeFromDay': 'Odebrat ze dne',
+ 'inspector.addToDay': 'Přidat ke dni',
+ 'inspector.confirmedRes': 'Potvrzená rezervace',
+ 'inspector.pendingRes': 'Čekající rezervace',
+ 'inspector.google': 'Otevřít v Google Mapách',
+ 'inspector.website': 'Otevřít webové stránky',
+ 'inspector.addRes': 'Rezervace',
+ 'inspector.editRes': 'Upravit rezervaci',
+ 'inspector.participants': 'Účastníci',
+
+ // Rezervace (Reservations)
+ 'reservations.title': 'Rezervace',
+ 'reservations.empty': 'Zatím žádné rezervace',
+ 'reservations.emptyHint': 'Přidejte rezervace letů, hotelů a dalších',
+ 'reservations.add': 'Přidat rezervaci',
+ 'reservations.addManual': 'Ruční rezervace',
+ 'reservations.placeHint': 'Tip: Rezervace je nejlepší vytvářet přímo z místa – propojí se tak s denním plánem.',
+ 'reservations.confirmed': 'Potvrzeno',
+ 'reservations.pending': 'Čeká na potvrzení',
+ 'reservations.summary': '{confirmed} potvrzených, {pending} čekajících',
+ 'reservations.fromPlan': 'Z plánu',
+ 'reservations.showFiles': 'Zobrazit soubory',
+ 'reservations.editTitle': 'Upravit rezervaci',
+ 'reservations.status': 'Stav',
+ 'reservations.datetime': 'Datum a čas',
+ 'reservations.startTime': 'Čas začátku',
+ 'reservations.endTime': 'Čas konce',
+ 'reservations.date': 'Datum',
+ 'reservations.time': 'Čas',
+ 'reservations.timeAlt': 'Čas (alternativní, např. 19:30)',
+ 'reservations.notes': 'Poznámky',
+ 'reservations.notesPlaceholder': 'Další poznámky...',
+ 'reservations.meta.airline': 'Letecká společnost',
+ 'reservations.meta.flightNumber': 'Číslo letu',
+ 'reservations.meta.from': 'Z',
+ 'reservations.meta.to': 'Do',
+ 'reservations.meta.trainNumber': 'Číslo vlaku',
+ 'reservations.meta.platform': 'Nástupiště',
+ 'reservations.meta.seat': 'Sedadlo',
+ 'reservations.meta.checkIn': 'Check-in',
+ 'reservations.meta.checkOut': 'Check-out',
+ 'reservations.meta.linkAccommodation': 'Ubytování',
+ 'reservations.meta.pickAccommodation': 'Propojit s ubytováním',
+ 'reservations.meta.noAccommodation': 'Nic',
+ 'reservations.meta.hotelPlace': 'Ubytování',
+ 'reservations.meta.pickHotel': 'Vybrat ubytování',
+ 'reservations.meta.fromDay': 'Od dne',
+ 'reservations.meta.toDay': 'Do dne',
+ 'reservations.meta.selectDay': 'Vyberte den',
+ 'reservations.type.flight': 'Let',
+ 'reservations.type.hotel': 'Ubytování',
+ 'reservations.type.restaurant': 'Restaurace',
+ 'reservations.type.train': 'Vlak',
+ 'reservations.type.car': 'Pronájem auta',
+ 'reservations.type.cruise': 'Plavba',
+ 'reservations.type.event': 'Událost',
+ 'reservations.type.tour': 'Prohlídka',
+ 'reservations.type.other': 'Jiné',
+ 'reservations.confirm.delete': 'Opravdu chcete smazat rezervaci „{name}”?',
+ 'reservations.confirm.deleteTitle': 'Smazat rezervaci?',
+ 'reservations.confirm.deleteBody': '„{name}” bude trvale smazána.',
+ 'reservations.toast.updated': 'Rezervace aktualizována',
+ 'reservations.toast.removed': 'Rezervace smazána',
+ 'reservations.toast.fileUploaded': 'Soubor byl nahrán',
+ 'reservations.toast.uploadError': 'Nahrávání se nezdařilo',
+ 'reservations.newTitle': 'Nová rezervace',
+ 'reservations.bookingType': 'Typ rezervace',
+ 'reservations.titleLabel': 'Název',
+ 'reservations.titlePlaceholder': 'např. Let LH123, Hotel Adlon...',
+ 'reservations.locationAddress': 'Místo / Adresa',
+ 'reservations.locationPlaceholder': 'Adresa, letiště, hotel...',
+ 'reservations.confirmationCode': 'Rezervační kód',
+ 'reservations.confirmationPlaceholder': 'např. ABC12345',
+ 'reservations.day': 'Den',
+ 'reservations.noDay': 'Žádný den',
+ 'reservations.place': 'Místo',
+ 'reservations.noPlace': 'Žádné místo',
+ 'reservations.pendingSave': 'bude uloženo…',
+ 'reservations.uploading': 'Nahrávání...',
+ 'reservations.attachFile': 'Přiložit soubor',
+ 'reservations.linkExisting': 'Propojit stávající soubor',
+ 'reservations.toast.saveError': 'Uložení se nezdařilo',
+ 'reservations.toast.updateError': 'Aktualizace se nezdařila',
+ 'reservations.toast.deleteError': 'Smazání se nezdařilo',
+ 'reservations.confirm.remove': 'Odstranit rezervaci pro „{name}”?',
+ 'reservations.linkAssignment': 'Propojit s přiřazením dne',
+ 'reservations.pickAssignment': 'Vyberte přiřazení z vašeho plánu...',
+ 'reservations.noAssignment': 'Bez propojení (samostatné)',
+
+ // Rozpočet (Budget)
+ 'budget.title': 'Rozpočet',
+ 'budget.emptyTitle': 'Zatím nebyl vytvořen žádný rozpočet',
+ 'budget.emptyText': 'Vytvořte kategorie a položky pro plánování cestovního rozpočtu',
+ 'budget.emptyPlaceholder': 'Zadejte název kategorie...',
+ 'budget.createCategory': 'Vytvořit kategorii',
+ 'budget.category': 'Kategorie',
+ 'budget.categoryName': 'Název kategorie',
+ 'budget.table.name': 'Název',
+ 'budget.table.total': 'Celkem',
+ 'budget.table.persons': 'Osoby',
+ 'budget.table.days': 'Dní',
+ 'budget.table.perPerson': 'Na osobu',
+ 'budget.table.perDay': 'Za den',
+ 'budget.table.perPersonDay': 'Os. / den',
+ 'budget.table.note': 'Poznámka',
+ 'budget.newEntry': 'Nová položka',
+ 'budget.defaultEntry': 'Nová položka',
+ 'budget.defaultCategory': 'Nová kategorie',
+ 'budget.total': 'Celkem',
+ 'budget.totalBudget': 'Celkový rozpočet',
+ 'budget.byCategory': 'Podle kategorie',
+ 'budget.editTooltip': 'Klikněte pro úpravu',
+ 'budget.confirm.deleteCategory': 'Opravdu chcete smazat kategorii „{name}” s {count} položkami?',
+ 'budget.deleteCategory': 'Smazat kategorii',
+ 'budget.perPerson': 'Na osobu',
+ 'budget.paid': 'Zaplaceno',
+ 'budget.open': 'Nezaplaceno',
+ 'budget.noMembers': 'Žádní členové nebyli přiřazeni',
+ 'budget.settlement': 'Vyúčtování',
+ 'budget.settlementInfo': 'Klikněte na avatar člena u rozpočtové položky pro zelené označení – to znamená, že zaplatil. Vyúčtování pak ukazuje, kdo komu a kolik dluží.',
+ 'budget.netBalances': 'Čisté zůstatky',
+
+ // Soubory (Files)
+ 'files.title': 'Soubory',
+ 'files.count': '{count} souborů',
+ 'files.countSingular': '1 soubor',
+ 'files.uploaded': '{count} nahráno',
+ 'files.uploadError': 'Nahrávání se nezdařilo',
+ 'files.dropzone': 'Přetáhněte soubory sem',
+ 'files.dropzoneHint': 'nebo klikněte pro výběr',
+ 'files.allowedTypes': 'Obrázky, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB',
+ 'files.uploading': 'Nahrávání...',
+ 'files.filterAll': 'Vše',
+ 'files.filterPdf': 'PDF',
+ 'files.filterImages': 'Obrázky',
+ 'files.filterDocs': 'Dokumenty',
+ 'files.filterCollab': 'Poznámky spolupráce',
+ 'files.sourceCollab': 'Z poznámek spolupráce',
+ 'files.empty': 'Zatím žádné soubory',
+ 'files.emptyHint': 'Nahrajte soubory k vaší cestě',
+ 'files.openTab': 'Otevřít v nové kartě',
+ 'files.confirm.delete': 'Opravdu chcete smazat tento soubor?',
+ 'files.toast.deleted': 'Soubor byl smazán',
+ 'files.toast.deleteError': 'Nepodařilo se smazat soubor',
+ 'files.sourcePlan': 'Denní plán',
+ 'files.sourceBooking': 'Rezervace',
+ 'files.attach': 'Přiložit',
+ 'files.pasteHint': 'Můžete také vložit obrázek ze schránky (Ctrl+V)',
+ 'files.trash': 'Koš',
+ 'files.trashEmpty': 'Koš je prázdný',
+ 'files.emptyTrash': 'Vysypat koš',
+ 'files.restore': 'Obnovit',
+ 'files.star': 'Označit hvězdičkou',
+ 'files.unstar': 'Odebrat hvězdičku',
+ 'files.assign': 'Přiřadit',
+ 'files.assignTitle': 'Přiřadit soubor',
+ 'files.assignPlace': 'Místo',
+ 'files.assignBooking': 'Rezervace',
+ 'files.unassigned': 'Nepřiřazeno',
+ 'files.unlink': 'Zrušit propojení',
+ 'files.toast.trashed': 'Přesunuto do koše',
+ 'files.toast.restored': 'Soubor byl obnoven',
+ 'files.toast.trashEmptied': 'Koš byl vysypán',
+ 'files.toast.assigned': 'Soubor byl přiřazen',
+ 'files.toast.assignError': 'Přiřazení se nezdařilo',
+ 'files.toast.restoreError': 'Obnovení se nezdařilo',
+ 'files.confirm.permanentDelete': 'Trvale smazat tento soubor? Tuto akci nelze vrátit.',
+ 'files.confirm.emptyTrash': 'Trvale smazat všechny soubory v koši? Tuto akci nelze vrátit.',
+ 'files.noteLabel': 'Poznámka',
+ 'files.notePlaceholder': 'Přidat poznámku...',
+
+ // Balení (Packing)
+ 'packing.title': 'Seznam věcí',
+ 'packing.empty': 'Seznam věcí je prázdný',
+ 'packing.import': 'Importovat',
+ 'packing.importTitle': 'Importovat seznam',
+ 'packing.importHint': 'Jedna položka na řádek. Formát: Kategorie, Název, Váha v g (volitelné), Zavazadlo (volitelné), checked/unchecked (volitelné)',
+ 'packing.importPlaceholder': 'Hygiena, Zubní kartáček\nOblečení, Trička, 200\nDokumenty, Pas, , Příruční zavazadlo\nElektronika, Nabíječka, 50, Kufr, checked',
+ 'packing.importCsv': 'Načíst CSV/TXT',
+ 'packing.importAction': 'Importovat {count}',
+ 'packing.importSuccess': '{count} položek importováno',
+ 'packing.importError': 'Import se nezdařil',
+ 'packing.importEmpty': 'Žádné položky k importu',
+ 'packing.progress': '{packed} z {total} zabaleno ({percent} %)',
+ 'packing.clearChecked': 'Odstranit {count} hotových',
+ 'packing.clearCheckedShort': 'Odstranit {count}',
+ 'packing.suggestions': 'Návrhy',
+ 'packing.suggestionsTitle': 'Přidat návrhy',
+ 'packing.allSuggested': 'Všechny návrhy byly přidány',
+ 'packing.allPacked': 'Vše je zabaleno!',
+ 'packing.addPlaceholder': 'Přidat novou položku...',
+ 'packing.categoryPlaceholder': 'Kategorie...',
+ 'packing.filterAll': 'Vše',
+ 'packing.filterOpen': 'K zabalení',
+ 'packing.filterDone': 'Hotovo',
+ 'packing.emptyTitle': 'Seznam věcí je prázdný',
+ 'packing.emptyHint': 'Přidejte položky nebo použijte návrhy',
+ 'packing.emptyFiltered': 'Žádné položky neodpovídají filtru',
+ 'packing.menuRename': 'Přejmenovat',
+ 'packing.menuCheckAll': 'Označit vše',
+ 'packing.menuUncheckAll': 'Odznačit vše',
+ 'packing.menuDeleteCat': 'Smazat kategorii',
+ 'packing.assignUser': 'Přiřadit uživateli',
+ 'packing.noMembers': 'Žádní členové cesty',
+ 'packing.addItem': 'Přidat položku',
+ 'packing.addItemPlaceholder': 'Název položky...',
+ 'packing.addCategory': 'Přidat kategorii',
+ 'packing.newCategoryPlaceholder': 'Název kategorie (např. Oblečení)',
+ 'packing.applyTemplate': 'Použít šablonu',
+ 'packing.template': 'Šablona',
+ 'packing.templateApplied': '{count} položek přidáno ze šablony',
+ 'packing.templateError': 'Šablonu se nepodařilo použít',
+ 'packing.bags': 'Zavazadla',
+ 'packing.noBag': 'Nepřiřazeno',
+ 'packing.totalWeight': 'Celková váha',
+ 'packing.bagName': 'Název zavazadla...',
+ 'packing.addBag': 'Přidat zavazadlo',
+ 'packing.changeCategory': 'Změnit kategorii',
+ 'packing.confirm.clearChecked': 'Opravdu chcete odstranit {count} zabalených položek?',
+ 'packing.confirm.deleteCat': 'Opravdu chcete smazat kategorii „{name}" s {count} položkami?',
+ 'packing.defaultCategory': 'Ostatní',
+ 'packing.toast.saveError': 'Uložení se nezdařilo',
+ 'packing.toast.deleteError': 'Smazání se nezdařilo',
+ 'packing.toast.renameError': 'Přejmenování se nezdařilo',
+ 'packing.toast.addError': 'Přidání se nezdařilo',
+
+ // Návrhy balení (Packing suggestions)
+ 'packing.suggestions.items': [
+ { name: 'Pas', category: 'Dokumenty' },
+ { name: 'Občanský průkaz', category: 'Dokumenty' },
+ { name: 'Cestovní pojištění', category: 'Dokumenty' },
+ { name: 'Letenky', category: 'Dokumenty' },
+ { name: 'Platební karta', category: 'Finance' },
+ { name: 'Hotovost', category: 'Finance' },
+ { name: 'Víza', category: 'Dokumenty' },
+ { name: 'Trička', category: 'Oblečení' },
+ { name: 'Kalhoty', category: 'Oblečení' },
+ { name: 'Spodní prádlo', category: 'Oblečení' },
+ { name: 'Ponožky', category: 'Oblečení' },
+ { name: 'Bunda', category: 'Oblečení' },
+ { name: 'Pyžamo', category: 'Oblečení' },
+ { name: 'Plavky', category: 'Oblečení' },
+ { name: 'Pláštěnka', category: 'Oblečení' },
+ { name: 'Pohodlné boty', category: 'Oblečení' },
+ { name: 'Zubní kartáček', category: 'Hygiena' },
+ { name: 'Zubní pasta', category: 'Hygiena' },
+ { name: 'Šampón', category: 'Hygiena' },
+ { name: 'Deodorant', category: 'Hygiena' },
+ { name: 'Opalovací krém', category: 'Hygiena' },
+ { name: 'Holicí strojek', category: 'Hygiena' },
+ { name: 'Nabíječka', category: 'Elektronika' },
+ { name: 'Powerbanka', category: 'Elektronika' },
+ { name: 'Sluchátka', category: 'Elektronika' },
+ { name: 'Cestovní adaptér', category: 'Elektronika' },
+ { name: 'Fotoaparát', category: 'Elektronika' },
+ { name: 'Léky proti bolesti', category: 'Zdraví' },
+ { name: 'Náplasti', category: 'Zdraví' },
+ { name: 'Dezinfekce', category: 'Zdraví' },
+ ],
+
+ // Členové / Sdílení (Members)
+ 'members.shareTrip': 'Sdílet cestu',
+ 'members.inviteUser': 'Pozvat uživatele',
+ 'members.selectUser': 'Vyberte uživatele…',
+ 'members.invite': 'Pozvat',
+ 'members.allHaveAccess': 'Všichni uživatelé již mají přístup.',
+ 'members.access': 'Přístup',
+ 'members.person': 'osoba',
+ 'members.persons': 'osob',
+ 'members.you': 'vy',
+ 'members.owner': 'Vlastník',
+ 'members.leaveTrip': 'Opustit cestu',
+ 'members.removeAccess': 'Odebrat přístup',
+ 'members.confirmLeave': 'Opustit cestu? Ztratíte přístup.',
+ 'members.confirmRemove': 'Odebrat přístup tomuto uživateli?',
+ 'members.loadError': 'Nepodařilo se načíst členy',
+ 'members.added': 'přidán/a',
+ 'members.addError': 'Nepodařilo se přidat',
+ 'members.removed': 'Člen odebrán',
+ 'members.removeError': 'Nepodařilo se odebrat',
+
+ // Kategorie (Admin)
+ 'categories.title': 'Kategorie',
+ 'categories.subtitle': 'Správa kategorií pro místa',
+ 'categories.new': 'Nová kategorie',
+ 'categories.empty': 'Zatím žádné kategorie',
+ 'categories.namePlaceholder': 'Název kategorie',
+ 'categories.icon': 'Ikona',
+ 'categories.color': 'Barva',
+ 'categories.customColor': 'Vybrat vlastní barvu',
+ 'categories.preview': 'Náhled',
+ 'categories.defaultName': 'Kategorie',
+ 'categories.update': 'Aktualizovat',
+ 'categories.create': 'Vytvořit',
+ 'categories.confirm.delete': 'Smazat kategorii? Místa v této kategorii nebudou smazána.',
+ 'categories.toast.loadError': 'Nepodařilo se načíst kategorie',
+ 'categories.toast.nameRequired': 'Prosím zadejte název',
+ 'categories.toast.updated': 'Kategorie aktualizována',
+ 'categories.toast.created': 'Kategorie vytvořena',
+ 'categories.toast.saveError': 'Uložení se nezdařilo',
+ 'categories.toast.deleted': 'Kategorie smazána',
+ 'categories.toast.deleteError': 'Smazání se nezdařilo',
+
+ // Zálohování (Backup)
+ 'backup.title': 'Záloha dat',
+ 'backup.subtitle': 'Databáze a všechny nahrané soubory',
+ 'backup.refresh': 'Obnovit',
+ 'backup.upload': 'Nahrát zálohu',
+ 'backup.uploading': 'Nahrávání…',
+ 'backup.create': 'Vytvořit zálohu',
+ 'backup.creating': 'Vytváření…',
+ 'backup.empty': 'Zatím žádné zálohy',
+ 'backup.createFirst': 'Vytvořit první zálohu',
+ 'backup.download': 'Stáhnout',
+ 'backup.restore': 'Obnovit',
+ 'backup.confirm.restore': 'Obnovit zálohu „{name}"?\n\nVšechna aktuální data budou nahrazena zálohou.',
+ 'backup.confirm.uploadRestore': 'Nahrát a obnovit zálohu „{name}"?\n\nVšechna aktuální data budou přepsána.',
+ 'backup.confirm.delete': 'Smazat zálohu „{name}"?',
+ 'backup.toast.loadError': 'Nepodařilo se načíst zálohy',
+ 'backup.toast.created': 'Záloha byla úspěšně vytvořena',
+ 'backup.toast.createError': 'Nepodařilo se vytvořit zálohu',
+ 'backup.toast.restored': 'Záloha obnovena. Stránka se znovu načte…',
+ 'backup.toast.restoreError': 'Obnovení se nezdařilo',
+ 'backup.toast.uploadError': 'Nahrávání se nezdařilo',
+ 'backup.toast.deleted': 'Záloha smazána',
+ 'backup.toast.deleteError': 'Smazání se nezdařilo',
+ 'backup.toast.downloadError': 'Stahování se nezdařilo',
+ 'backup.toast.settingsSaved': 'Nastavení automatického zálohování uloženo',
+ 'backup.toast.settingsError': 'Nepodařilo se uložit nastavení',
+ 'backup.auto.title': 'Automatické zálohování',
+ 'backup.auto.subtitle': 'Automatické zálohování podle plánu',
+ 'backup.auto.enable': 'Povolit automatické zálohování',
+ 'backup.auto.enableHint': 'Zálohy budou vytvářeny automaticky podle zvoleného plánu',
+ 'backup.auto.interval': 'Interval',
+ 'backup.auto.hour': 'Spustit v hodinu',
+ 'backup.auto.hourHint': 'Místní čas serveru (formát {format})',
+ 'backup.auto.dayOfWeek': 'Den v týdnu',
+ 'backup.auto.dayOfMonth': 'Den v měsíci',
+ 'backup.auto.dayOfMonthHint': 'Omezeno na 1–28 pro kompatibilitu se všemi měsíci',
+ 'backup.auto.scheduleSummary': 'Plán',
+ 'backup.auto.summaryDaily': 'Každý den v {hour}:00',
+ 'backup.auto.summaryWeekly': 'Každý {day} v {hour}:00',
+ 'backup.auto.summaryMonthly': '{day}. každého měsíce v {hour}:00',
+ 'backup.auto.envLocked': 'Docker',
+ 'backup.auto.envLockedHint': 'Automatické zálohování je konfigurováno přes Docker proměnné prostředí. Pro změnu nastavení aktualizujte docker-compose.yml a restartujte kontejner.',
+ 'backup.auto.copyEnv': 'Zkopírovat Docker proměnné',
+ 'backup.auto.envCopied': 'Docker proměnné prostředí zkopírovány do schránky',
+ 'backup.auto.keepLabel': 'Smazat staré zálohy po',
+ 'backup.dow.sunday': 'Ne',
+ 'backup.dow.monday': 'Po',
+ 'backup.dow.tuesday': 'Út',
+ 'backup.dow.wednesday': 'St',
+ 'backup.dow.thursday': 'Čt',
+ 'backup.dow.friday': 'Pá',
+ 'backup.dow.saturday': 'So',
+ 'backup.interval.hourly': 'Každou hodinu',
+ 'backup.interval.daily': 'Denně',
+ 'backup.interval.weekly': 'Týdně',
+ 'backup.interval.monthly': 'Měsíčně',
+ 'backup.keep.1day': '1 den',
+ 'backup.keep.3days': '3 dny',
+ 'backup.keep.7days': '7 dní',
+ 'backup.keep.14days': '14 dní',
+ 'backup.keep.30days': '30 dní',
+ 'backup.keep.forever': 'Uchovávat navždy',
+
+ // Fotky
+ 'photos.allDays': 'Všechny dny',
+ 'photos.noPhotos': 'Zatím žádné fotky',
+ 'photos.uploadHint': 'Nahrajte své cestovní fotky',
+ 'photos.clickToSelect': 'nebo klikněte pro výběr',
+ 'photos.linkPlace': 'Propojit s místem',
+ 'photos.noPlace': 'Žádné místo',
+ 'photos.uploadN': 'Nahrát {n} fotek',
+
+ // Obnovení zálohy
+ 'backup.restoreConfirmTitle': 'Obnovit zálohu?',
+ 'backup.restoreWarning': 'Všechna aktuální data (cesty, místa, uživatelé, nahrané soubory) budou trvale nahrazena zálohou. Tuto akci nelze vrátit.',
+ 'backup.restoreTip': 'Tip: Před obnovením vytvořte zálohu aktuálního stavu.',
+ 'backup.restoreConfirm': 'Ano, obnovit',
+
+ // PDF
+ 'pdf.travelPlan': 'Cestovní plán',
+ 'pdf.planned': 'Naplánováno',
+ 'pdf.costLabel': 'Náklady EUR',
+ 'pdf.preview': 'Náhled PDF',
+ 'pdf.saveAsPdf': 'Uložit jako PDF',
+
+ // Plánovač (Planner)
+ 'planner.places': 'Místa',
+ 'planner.bookings': 'Rezervace',
+ 'planner.packingList': 'Seznam věcí',
+ 'planner.documents': 'Dokumenty',
+ 'planner.dayPlan': 'Denní plán',
+ 'planner.reservations': 'Rezervace',
+ 'planner.minTwoPlaces': 'Potřebujete alespoň 2 místa se souřadnicemi',
+ 'planner.noGeoPlaces': 'Žádná místa se souřadnicemi nejsou k dispozici',
+ 'planner.routeCalculated': 'Trasa vypočtena',
+ 'planner.routeCalcFailed': 'Trasu se nepodařilo vypočítat',
+ 'planner.routeError': 'Chyba při výpočtu trasy',
+ 'planner.routeOptimized': 'Trasa optimalizována',
+ 'planner.reservationUpdated': 'Rezervace aktualizována',
+ 'planner.reservationAdded': 'Rezervace přidána',
+ 'planner.confirmDeleteReservation': 'Smazat rezervaci?',
+ 'planner.reservationDeleted': 'Rezervace smazána',
+ 'planner.days': 'Dny',
+ 'planner.allPlaces': 'Všechna místa',
+ 'planner.totalPlaces': 'Celkem {n} míst',
+ 'planner.noDaysPlanned': 'Zatím nejsou naplánovány žádné dny',
+ 'planner.editTrip': 'Upravit cestu \u2192',
+ 'planner.placeOne': '1 místo',
+ 'planner.placeN': '{n} míst',
+ 'planner.addNote': 'Přidat poznámku',
+ 'planner.noEntries': 'Pro tento den nejsou žádné záznamy',
+ 'planner.addPlace': 'Přidat místo/aktivitu',
+ 'planner.addPlaceShort': '+ Přidat místo/aktivitu',
+ 'planner.resPending': 'Rezervace čeká · ',
+ 'planner.resConfirmed': 'Rezervace potvrzena · ',
+ 'planner.notePlaceholder': 'Poznámka\u2026',
+ 'planner.noteTimePlaceholder': 'Čas (volitelné)',
+ 'planner.noteExamplePlaceholder': 'např. S3 ve 14:30 z hlavního nádraží, trajekt z přístaviště 7, přestávka na oběd\u2026',
+ 'planner.totalCost': 'Celkové náklady',
+ 'planner.searchPlaces': 'Hledat místa\u2026',
+ 'planner.allCategories': 'Všechny kategorie',
+ 'planner.noPlacesFound': 'Žádná místa nenalezena',
+ 'planner.addFirstPlace': 'Přidat první místo',
+ 'planner.noReservations': 'Žádné rezervace',
+ 'planner.addFirstReservation': 'Přidat první rezervaci',
+ 'planner.new': 'Nový',
+ 'planner.addToDay': '+ Den',
+ 'planner.calculating': 'Počítání\u2026',
+ 'planner.route': 'Trasa',
+ 'planner.optimize': 'Optimalizovat',
+ 'planner.openGoogleMaps': 'Otevřít v Google Mapách',
+ 'planner.selectDayHint': 'Vyberte den ze seznamu vlevo pro zobrazení denního plánu',
+ 'planner.noPlacesForDay': 'Zatím žádná místa pro tento den',
+ 'planner.addPlacesLink': 'Přidat místa \u2192',
+ 'planner.minTotal': 'min. celkem',
+ 'planner.noReservation': 'Žádná rezervace',
+ 'planner.removeFromDay': 'Odebrat ze dne',
+ 'planner.addToThisDay': 'Přidat ke dni',
+ 'planner.overview': 'Přehled',
+ 'planner.noDays': 'Zatím žádné dny',
+ 'planner.editTripToAddDays': 'Upravte cestu pro přidání dnů',
+ 'planner.dayCount': '{n} dní',
+ 'planner.clickToUnlock': 'Klikněte pro odemčení',
+ 'planner.keepPosition': 'Zachovat pozici při optimalizaci trasy',
+ 'planner.dayDetails': 'Podrobnosti dne',
+ 'planner.dayN': 'Den {n}',
+
+ // Statistiky (Dashboard Stats)
+ 'stats.countries': 'Země',
+ 'stats.cities': 'Města',
+ 'stats.trips': 'Cesty',
+ 'stats.places': 'Místa',
+ 'stats.worldProgress': 'Průzkum světa',
+ 'stats.visited': 'navštíveno',
+ 'stats.remaining': 'zbývá',
+ 'stats.visitedCountries': 'Navštívené země',
+
+ // Detail dne (Day Detail Panel)
+ 'day.precipProb': 'Pravděpodobnost srážek',
+ 'day.precipitation': 'Srážky',
+ 'day.wind': 'Vítr',
+ 'day.sunrise': 'Východ slunce',
+ 'day.sunset': 'Západ slunce',
+ 'day.hourlyForecast': 'Hodinová předpověď',
+ 'day.climateHint': 'Historické průměry — reálná předpověď je k dispozici do 16 dnů od tohoto data.',
+ 'day.noWeather': 'Nejsou k dispozici žádná data o počasí. Přidejte místo se souřadnicemi.',
+ 'day.overview': 'Denní přehled',
+ 'day.accommodation': 'Ubytování',
+ 'day.addAccommodation': 'Přidat ubytování',
+ 'day.hotelDayRange': 'Použít na dny',
+ 'day.noPlacesForHotel': 'Nejprve přidejte místa ke své cestě',
+ 'day.allDays': 'Vše',
+ 'day.checkIn': 'Check-in',
+ 'day.checkOut': 'Check-out',
+ 'day.confirmation': 'Potvrzení',
+ 'day.editAccommodation': 'Upravit ubytování',
+ 'day.reservations': 'Rezervace',
+
+ // Fotky / Immich
+ 'memories.title': 'Fotky',
+ 'memories.notConnected': 'Immich není připojen',
+ 'memories.notConnectedHint': 'Připojte svoji instanci Immich v Nastavení, abyste zde viděli fotky z cest.',
+ 'memories.noDates': 'Přidejte data k cestě pro načtení fotek.',
+ 'memories.noPhotos': 'Nenalezeny žádné fotky',
+ 'memories.noPhotosHint': 'V Immich nebyly nalezeny žádné fotky pro období této cesty.',
+ 'memories.photosFound': 'fotek',
+ 'memories.fromOthers': 'od ostatních',
+ 'memories.sharePhotos': 'Sdílet fotky',
+ 'memories.sharing': 'Sdílení',
+ 'memories.reviewTitle': 'Zkontrolujte své fotky',
+ 'memories.reviewHint': 'Klikněte na fotky pro vyloučení ze sdílení.',
+ 'memories.shareCount': 'Sdílet {count} fotek',
+ 'memories.immichUrl': 'URL serveru Immich',
+ 'memories.immichApiKey': 'API klíč',
+ 'memories.testConnection': 'Otestovat připojení',
+ 'memories.connected': 'Připojeno',
+ 'memories.disconnected': 'Nepřipojeno',
+ 'memories.connectionSuccess': 'Připojeno k Immich',
+ 'memories.connectionError': 'Nepodařilo se připojit k Immich',
+ 'memories.saved': 'Nastavení Immich uloženo',
+ 'memories.addPhotos': 'Přidat fotky',
+ 'memories.selectPhotos': 'Vybrat fotky z Immich',
+ 'memories.selectHint': 'Klepněte na fotky pro jejich výběr.',
+ 'memories.selected': 'vybráno',
+ 'memories.addSelected': 'Přidat {count} fotek',
+ 'memories.alreadyAdded': 'Přidáno',
+ 'memories.private': 'Soukromé',
+ 'memories.stopSharing': 'Zastavit sdílení',
+ 'memories.oldest': 'Nejstarší',
+ 'memories.newest': 'Nejnovější',
+ 'memories.allLocations': 'Všechna místa',
+ 'memories.tripDates': 'Data cesty',
+ 'memories.allPhotos': 'Všechny fotky',
+ 'memories.confirmShareTitle': 'Sdílet se členy cesty?',
+ 'memories.confirmShareHint': '{count} fotek bude viditelných pro všechny členy této cesty. Jednotlivé fotky můžete později nastavit jako soukromé.',
+ 'memories.confirmShareButton': 'Sdílet fotky',
+
+ // Spolupráce (Collab)
+ 'collab.tabs.chat': 'Chat',
+ 'collab.tabs.notes': 'Poznámky',
+ 'collab.tabs.polls': 'Hlasování',
+ 'collab.whatsNext.title': 'Co následuje',
+ 'collab.whatsNext.today': 'Dnes',
+ 'collab.whatsNext.tomorrow': 'Zítra',
+ 'collab.whatsNext.empty': 'Žádné nadcházející aktivity',
+ 'collab.whatsNext.until': 'do',
+ 'collab.whatsNext.emptyHint': 'Aktivity s časem se zde zobrazí',
+ 'collab.chat.send': 'Odeslat',
+ 'collab.chat.placeholder': 'Napište zprávu...',
+ 'collab.chat.empty': 'Začněte konverzaci',
+ 'collab.chat.emptyHint': 'Zprávy jsou sdíleny se všemi členy cesty',
+ 'collab.chat.emptyDesc': 'Sdílejte nápady, plány a novinky se svou cestovatelskou skupinou',
+ 'collab.chat.today': 'Dnes',
+ 'collab.chat.yesterday': 'Včera',
+ 'collab.chat.deletedMessage': 'smazal zprávu',
+ 'collab.chat.loadMore': 'Načíst starší zprávy',
+ 'collab.chat.justNow': 'právě teď',
+ 'collab.chat.minutesAgo': 'před {n} min',
+ 'collab.chat.hoursAgo': 'před {n} h',
+ 'collab.notes.title': 'Poznámky',
+ 'collab.notes.new': 'Nová poznámka',
+ 'collab.notes.empty': 'Zatím žádné poznámky',
+ 'collab.notes.emptyHint': 'Začněte zapisovat nápady a plány',
+ 'collab.notes.all': 'Vše',
+ 'collab.notes.titlePlaceholder': 'Poznámka...',
+ 'collab.notes.noCategory': 'Bez kategorie',
+ 'collab.notes.color': 'Barva',
+ 'collab.notes.save': 'Uložit',
+ 'collab.notes.cancel': 'Zrušit',
+ 'collab.notes.edit': 'Upravit',
+ 'collab.notes.delete': 'Smazat',
+ 'collab.notes.pin': 'Připnout',
+ 'collab.notes.unpin': 'Odepnout',
+ 'collab.notes.daysAgo': 'před {n} dny',
+ 'collab.notes.categorySettings': 'Spravovat kategorie',
+ 'collab.notes.create': 'Vytvořit',
+ 'collab.notes.website': 'Webové stránky',
+ 'collab.notes.websitePlaceholder': 'https://...',
+ 'collab.notes.attachFiles': 'Přiložit soubory',
+ 'collab.notes.noCategoriesYet': 'Zatím žádné kategorie',
+ 'collab.notes.emptyDesc': 'Vytvořte poznámku a začněte',
+ 'collab.notes.contentPlaceholder': 'Napište něco...',
+ 'collab.notes.categoryPlaceholder': 'Kategorie',
+ 'collab.notes.newCategory': 'Nová kategorie...',
+ 'collab.notes.category': 'Kategorie',
+ 'collab.polls.title': 'Hlasování',
+ 'collab.polls.new': 'Nové hlasování',
+ 'collab.polls.empty': 'Zatím žádná hlasování',
+ 'collab.polls.emptyHint': 'Zeptejte se skupiny a hlasujte společně',
+ 'collab.polls.question': 'Otázka',
+ 'collab.polls.questionPlaceholder': 'Co bychom měli dělat?',
+ 'collab.polls.addOption': '+ Přidat možnost',
+ 'collab.polls.optionPlaceholder': 'Možnost {n}',
+ 'collab.polls.create': 'Vytvořit hlasování',
+ 'collab.polls.close': 'Uzavřít',
+ 'collab.polls.closed': 'Uzavřeno',
+ 'collab.polls.votes': '{n} hlasů',
+ 'collab.polls.vote': '{n} hlas',
+ 'collab.polls.multipleChoice': 'Více možností',
+ 'collab.polls.multiChoice': 'Více možností',
+ 'collab.polls.deadline': 'Termín',
+ 'collab.polls.option': 'Možnost',
+ 'collab.polls.options': 'Možnosti',
+ 'collab.polls.delete': 'Smazat',
+ 'collab.polls.closedSection': 'Uzavřené',
+}
+
+export default cs
diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts
index 840a4f2..341a36d 100644
--- a/client/src/i18n/translations/de.ts
+++ b/client/src/i18n/translations/de.ts
@@ -139,8 +139,77 @@ const de: Record = {
'settings.temperature': 'Temperatureinheit',
'settings.timeFormat': 'Zeitformat',
'settings.routeCalculation': 'Routenberechnung',
+ 'settings.blurBookingCodes': 'Buchungscodes verbergen',
+ 'settings.notifications': 'Benachrichtigungen',
+ 'settings.notifyTripInvite': 'Trip-Einladungen',
+ 'settings.notifyBookingChange': 'Buchungsänderungen',
+ 'settings.notifyTripReminder': 'Trip-Erinnerungen',
+ 'settings.notifyVacayInvite': 'Vacay Fusion-Einladungen',
+ 'settings.notifyPhotosShared': 'Geteilte Fotos (Immich)',
+ 'settings.notifyCollabMessage': 'Chat-Nachrichten (Collab)',
+ 'settings.notifyPackingTagged': 'Packliste: Zuweisungen',
+ 'settings.notifyWebhook': 'Webhook-Benachrichtigungen',
+ 'admin.smtp.title': 'E-Mail & Benachrichtigungen',
+ 'admin.smtp.hint': 'SMTP-Konfiguration für E-Mail-Benachrichtigungen. Optional: Webhook-URL für Discord, Slack, etc.',
+ 'admin.smtp.testButton': 'Test-E-Mail senden',
+ 'admin.smtp.testSuccess': 'Test-E-Mail erfolgreich gesendet',
+ 'admin.smtp.testFailed': 'Test-E-Mail fehlgeschlagen',
+ 'dayplan.icsTooltip': 'Kalender exportieren (ICS)',
+ 'share.linkTitle': 'Öffentlicher Link',
+ 'share.linkHint': 'Erstelle einen Link den jeder ohne Login nutzen kann, um diese Reise anzuschauen. Nur lesen — keine Bearbeitung möglich.',
+ 'share.createLink': 'Link erstellen',
+ 'share.deleteLink': 'Link löschen',
+ 'share.createError': 'Link konnte nicht erstellt werden',
+ 'common.copy': 'Kopieren',
+ 'common.copied': 'Kopiert',
+ 'share.permMap': 'Karte & Plan',
+ 'share.permBookings': 'Buchungen',
+ 'share.permPacking': 'Packliste',
+ 'shared.expired': 'Link abgelaufen oder ungültig',
+ 'shared.expiredHint': 'Dieser geteilte Reise-Link ist nicht mehr aktiv.',
+ 'shared.readOnly': 'Nur-Lesen Ansicht',
+ 'shared.tabPlan': 'Plan',
+ 'shared.tabBookings': 'Buchungen',
+ 'shared.tabPacking': 'Packliste',
+ 'shared.tabBudget': 'Budget',
+ 'shared.tabChat': 'Chat',
+ 'shared.days': 'Tage',
+ 'shared.places': 'Orte',
+ 'shared.other': 'Sonstige',
+ 'shared.totalBudget': 'Gesamtbudget',
+ 'shared.messages': 'Nachrichten',
+ 'shared.sharedVia': 'Geteilt über',
+ 'shared.confirmed': 'Bestätigt',
+ 'shared.pending': 'Ausstehend',
+ 'share.permBudget': 'Budget',
+ 'share.permCollab': 'Chat',
'settings.on': 'An',
'settings.off': 'Aus',
+ 'settings.mcp.title': 'MCP-Konfiguration',
+ 'settings.mcp.endpoint': 'MCP-Endpunkt',
+ 'settings.mcp.clientConfig': 'Client-Konfiguration',
+ 'settings.mcp.clientConfigHint': 'Ersetze durch ein API-Token aus der Liste unten. Der Pfad zu npx muss ggf. für dein System angepasst werden (z. B. C:\\PROGRA~1\\nodejs\\npx.cmd unter Windows).',
+ 'settings.mcp.copy': 'Kopieren',
+ 'settings.mcp.copied': 'Kopiert!',
+ 'settings.mcp.apiTokens': 'API-Tokens',
+ 'settings.mcp.createToken': 'Neuen Token erstellen',
+ 'settings.mcp.noTokens': 'Noch keine Tokens. Erstelle einen, um MCP-Clients zu verbinden.',
+ 'settings.mcp.tokenCreatedAt': 'Erstellt',
+ 'settings.mcp.tokenUsedAt': 'Verwendet',
+ 'settings.mcp.deleteTokenTitle': 'Token löschen',
+ 'settings.mcp.deleteTokenMessage': 'Dieser Token wird sofort ungültig. Jeder MCP-Client, der ihn verwendet, verliert den Zugang.',
+ 'settings.mcp.modal.createTitle': 'API-Token erstellen',
+ 'settings.mcp.modal.tokenName': 'Token-Name',
+ 'settings.mcp.modal.tokenNamePlaceholder': 'z. B. Claude Desktop, Arbeits-Laptop',
+ 'settings.mcp.modal.creating': 'Wird erstellt…',
+ 'settings.mcp.modal.create': 'Token erstellen',
+ 'settings.mcp.modal.createdTitle': 'Token erstellt',
+ 'settings.mcp.modal.createdWarning': 'Dieser Token wird nur einmal angezeigt. Kopiere und speichere ihn jetzt — er kann nicht wiederhergestellt werden.',
+ 'settings.mcp.modal.done': 'Fertig',
+ 'settings.mcp.toast.created': 'Token erstellt',
+ 'settings.mcp.toast.createError': 'Token konnte nicht erstellt werden',
+ 'settings.mcp.toast.deleted': 'Token gelöscht',
+ 'settings.mcp.toast.deleteError': 'Token konnte nicht gelöscht werden',
'settings.account': 'Konto',
'settings.username': 'Benutzername',
'settings.email': 'E-Mail',
@@ -177,6 +246,14 @@ const de: Record = {
'settings.avatarError': 'Fehler beim Hochladen',
'settings.mfa.title': 'Zwei-Faktor-Authentifizierung (2FA)',
'settings.mfa.description': 'Zusätzlicher Schritt bei der Anmeldung mit E-Mail und Passwort. Nutze eine Authenticator-App (Google Authenticator, Authy, …).',
+ 'settings.mfa.requiredByPolicy': 'Dein Administrator verlangt Zwei-Faktor-Authentifizierung. Richte unten eine Authenticator-App ein, bevor du fortfährst.',
+ 'settings.mfa.backupTitle': 'Backup-Codes',
+ 'settings.mfa.backupDescription': 'Verwende diese Einmal-Codes, wenn du keinen Zugriff mehr auf deine Authenticator-App hast.',
+ 'settings.mfa.backupWarning': 'Jetzt speichern. Jeder Code kann nur einmal verwendet werden.',
+ 'settings.mfa.backupCopy': 'Codes kopieren',
+ 'settings.mfa.backupDownload': 'TXT herunterladen',
+ 'settings.mfa.backupPrint': 'Drucken / PDF',
+ 'settings.mfa.backupCopied': 'Backup-Codes kopiert',
'settings.mfa.enabled': '2FA ist für dein Konto aktiv.',
'settings.mfa.disabled': '2FA ist nicht aktiviert.',
'settings.mfa.setup': 'Authenticator einrichten',
@@ -271,6 +348,7 @@ const de: Record = {
'admin.tabs.users': 'Benutzer',
'admin.tabs.categories': 'Kategorien',
'admin.tabs.backup': 'Backup',
+ 'admin.tabs.audit': 'Audit-Protokoll',
'admin.stats.users': 'Benutzer',
'admin.stats.trips': 'Reisen',
'admin.stats.places': 'Orte',
@@ -320,6 +398,8 @@ const de: Record = {
'admin.tabs.settings': 'Einstellungen',
'admin.allowRegistration': 'Registrierung erlauben',
'admin.allowRegistrationHint': 'Neue Benutzer können sich selbst registrieren',
+ 'admin.requireMfa': 'Zwei-Faktor-Authentifizierung (2FA) für alle verlangen',
+ 'admin.requireMfaHint': 'Benutzer ohne 2FA müssen die Einrichtung unter Einstellungen abschließen, bevor sie die App nutzen können.',
'admin.apiKeys': 'API-Schlüssel',
'admin.apiKeysHint': 'Optional. Aktiviert erweiterte Ortsdaten wie Fotos und Wetter.',
'admin.mapsKey': 'Google Maps API Key',
@@ -374,8 +454,6 @@ const de: Record = {
'admin.tabs.addons': 'Addons',
'admin.addons.title': 'Addons',
'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um TREK nach deinen Wünschen anzupassen.',
- 'admin.addons.catalog.memories.name': 'Erinnerungen',
- 'admin.addons.catalog.memories.description': 'Geteilte Fotoalben für jede Reise',
'admin.addons.catalog.packing.name': 'Packliste',
'admin.addons.catalog.packing.description': 'Checklisten zum Kofferpacken für jede Reise',
'admin.addons.catalog.budget.name': 'Budget',
@@ -390,14 +468,18 @@ const de: Record = {
'admin.addons.catalog.collab.description': 'Echtzeit-Notizen, Umfragen und Chat für die Reiseplanung',
'admin.addons.catalog.memories.name': 'Fotos (Immich)',
'admin.addons.catalog.memories.description': 'Reisefotos über deine Immich-Instanz teilen',
+ 'admin.addons.catalog.mcp.name': 'MCP',
+ 'admin.addons.catalog.mcp.description': 'Model Context Protocol für die KI-Assistenten-Integration',
'admin.addons.subtitleBefore': 'Aktiviere oder deaktiviere Funktionen, um ',
'admin.addons.subtitleAfter': ' nach deinen Wünschen anzupassen.',
'admin.addons.enabled': 'Aktiviert',
'admin.addons.disabled': 'Deaktiviert',
'admin.addons.type.trip': 'Trip',
'admin.addons.type.global': 'Global',
+ 'admin.addons.type.integration': 'Integration',
'admin.addons.tripHint': 'Verfügbar als Tab innerhalb jedes Trips',
'admin.addons.globalHint': 'Verfügbar als eigenständiger Bereich in der Navigation',
+ 'admin.addons.integrationHint': 'Backend-Dienste und API-Integrationen ohne eigene Seite',
'admin.addons.toast.updated': 'Addon aktualisiert',
'admin.addons.toast.error': 'Addon konnte nicht aktualisiert werden',
'admin.addons.noAddons': 'Keine Addons verfügbar',
@@ -413,8 +495,37 @@ const de: Record = {
'admin.weather.requestsDesc': 'Kostenlos, kein API-Schlüssel erforderlich',
'admin.weather.locationHint': 'Das Wetter wird anhand des ersten Ortes mit Koordinaten im jeweiligen Tag berechnet. Ist kein Ort am Tag eingeplant, wird ein beliebiger Ort aus der Ortsliste als Referenz verwendet.',
+ // MCP Tokens
+ 'admin.tabs.mcpTokens': 'MCP-Tokens',
+ 'admin.mcpTokens.title': 'MCP-Tokens',
+ 'admin.mcpTokens.subtitle': 'API-Tokens aller Benutzer verwalten',
+ 'admin.mcpTokens.owner': 'Besitzer',
+ 'admin.mcpTokens.tokenName': 'Token-Name',
+ 'admin.mcpTokens.created': 'Erstellt',
+ 'admin.mcpTokens.lastUsed': 'Zuletzt verwendet',
+ 'admin.mcpTokens.never': 'Nie',
+ 'admin.mcpTokens.empty': 'Es wurden noch keine MCP-Tokens erstellt',
+ 'admin.mcpTokens.deleteTitle': 'Token löschen',
+ 'admin.mcpTokens.deleteMessage': 'Dieser Token wird sofort widerrufen. Der Benutzer verliert den MCP-Zugang über diesen Token.',
+ 'admin.mcpTokens.deleteSuccess': 'Token gelöscht',
+ 'admin.mcpTokens.deleteError': 'Token konnte nicht gelöscht werden',
+ 'admin.mcpTokens.loadError': 'Tokens konnten nicht geladen werden',
+
// GitHub
'admin.tabs.github': 'GitHub',
+
+ 'admin.audit.subtitle': 'Sicherheitsrelevante und administrative Ereignisse (Backups, Benutzer, MFA, Einstellungen).',
+ 'admin.audit.empty': 'Noch keine Audit-Einträge.',
+ 'admin.audit.refresh': 'Aktualisieren',
+ 'admin.audit.loadMore': 'Mehr laden',
+ 'admin.audit.showing': '{count} geladen · {total} gesamt',
+ 'admin.audit.col.time': 'Zeit',
+ 'admin.audit.col.user': 'Benutzer',
+ 'admin.audit.col.action': 'Aktion',
+ 'admin.audit.col.resource': 'Ressource',
+ 'admin.audit.col.ip': 'IP',
+ 'admin.audit.col.details': 'Details',
+
'admin.github.title': 'Update-Verlauf',
'admin.github.subtitle': 'Neueste Updates von {repo}',
'admin.github.latest': 'Aktuell',
@@ -544,6 +655,10 @@ const de: Record = {
'atlas.markVisited': 'Als besucht markieren',
'atlas.markVisitedHint': 'Dieses Land zur besuchten Liste hinzufügen',
'atlas.addToBucket': 'Zur Bucket List',
+ 'atlas.addPoi': 'Ort hinzufügen',
+ 'atlas.bucketNamePlaceholder': 'Name (Land, Stadt, Ort...)',
+ 'atlas.month': 'Monat',
+ 'atlas.year': 'Jahr',
'atlas.addToBucketHint': 'Als Wunschziel speichern',
'atlas.bucketWhen': 'Wann möchtest du dorthin reisen?',
'atlas.statsTab': 'Statistik',
@@ -607,6 +722,12 @@ const de: Record = {
// Day Plan Sidebar
'dayplan.emptyDay': 'Keine Orte für diesen Tag geplant',
+ 'dayplan.cannotReorderTransport': 'Buchungen mit fester Uhrzeit können nicht verschoben werden',
+ 'dayplan.confirmRemoveTimeTitle': 'Uhrzeit entfernen?',
+ 'dayplan.confirmRemoveTimeBody': 'Dieser Ort hat eine feste Uhrzeit ({time}). Durch das Verschieben wird die Uhrzeit entfernt und der Ort kann frei sortiert werden.',
+ 'dayplan.confirmRemoveTimeAction': 'Uhrzeit entfernen & verschieben',
+ 'dayplan.cannotDropOnTimed': 'Orte können nicht zwischen zeitgebundene Einträge geschoben werden',
+ 'dayplan.cannotBreakChronology': 'Die zeitliche Reihenfolge von Uhrzeiten und Buchungen darf nicht verletzt werden',
'dayplan.addNote': 'Notiz hinzufügen',
'dayplan.editNote': 'Notiz bearbeiten',
'dayplan.noteAdd': 'Notiz hinzufügen',
@@ -632,11 +753,17 @@ const de: Record = {
// Places Sidebar
'places.addPlace': 'Ort/Aktivität hinzufügen',
+ 'places.importGpx': 'GPX importieren',
+ 'places.gpxImported': '{count} Orte aus GPX importiert',
+ 'places.urlResolved': 'Ort aus URL importiert',
+ 'places.gpxError': 'GPX-Import fehlgeschlagen',
'places.assignToDay': 'Zu welchem Tag hinzufügen?',
'places.all': 'Alle',
'places.unplanned': 'Ungeplant',
'places.search': 'Orte suchen...',
'places.allCategories': 'Alle Kategorien',
+ 'places.categoriesSelected': 'Kategorien',
+ 'places.clearFilter': 'Filter zurücksetzen',
'places.count': '{count} Orte',
'places.countSingular': '1 Ort',
'places.allPlanned': 'Alle Orte sind eingeplant',
@@ -735,6 +862,8 @@ const de: Record = {
'reservations.type.tour': 'Tour',
'reservations.type.other': 'Sonstiges',
'reservations.confirm.delete': 'Möchtest du die Reservierung "{name}" wirklich löschen?',
+ 'reservations.confirm.deleteTitle': 'Buchung löschen?',
+ 'reservations.confirm.deleteBody': '"{name}" wird unwiderruflich gelöscht.',
'reservations.toast.updated': 'Reservierung aktualisiert',
'reservations.toast.removed': 'Reservierung gelöscht',
'reservations.toast.saveError': 'Fehler beim Speichern',
@@ -792,6 +921,9 @@ const de: Record = {
'budget.paid': 'Bezahlt',
'budget.open': 'Offen',
'budget.noMembers': 'Keine Teilnehmer zugewiesen',
+ 'budget.settlement': 'Ausgleich',
+ 'budget.settlementInfo': 'Klicke auf ein Mitglied-Bild bei einem Eintrag, um es grün zu markieren — das bedeutet, diese Person hat bezahlt. Der Ausgleich zeigt dann, wer wem wie viel schuldet.',
+ 'budget.netBalances': 'Netto-Salden',
// Files
'files.title': 'Dateien',
@@ -845,6 +977,15 @@ const de: Record = {
// Packing
'packing.title': 'Packliste',
'packing.empty': 'Packliste ist leer',
+ 'packing.import': 'Importieren',
+ 'packing.importTitle': 'Packliste importieren',
+ 'packing.importHint': 'Ein Eintrag pro Zeile. Format: Kategorie, Name, Gewicht in g (optional), Tasche (optional), checked/unchecked (optional)',
+ 'packing.importPlaceholder': 'Hygiene, Zahnbürste\nKleidung, T-Shirts, 200\nDokumente, Reisepass, , Handgepäck\nElektronik, Ladekabel, 50, Koffer, checked',
+ 'packing.importCsv': 'CSV/TXT laden',
+ 'packing.importAction': '{count} importieren',
+ 'packing.importSuccess': '{count} Einträge importiert',
+ 'packing.importError': 'Import fehlgeschlagen',
+ 'packing.importEmpty': 'Keine Einträge zum Importieren',
'packing.progress': '{packed} von {total} gepackt ({percent}%)',
'packing.clearChecked': '{count} abgehakte entfernen',
'packing.clearCheckedShort': '{count} entfernen',
@@ -996,7 +1137,27 @@ const de: Record = {
'backup.auto.enable': 'Auto-Backup aktivieren',
'backup.auto.enableHint': 'Backups werden automatisch nach dem gewählten Zeitplan erstellt',
'backup.auto.interval': 'Intervall',
+ 'backup.auto.hour': 'Ausführung um',
+ 'backup.auto.hourHint': 'Lokale Serverzeit ({format}-Format)',
+ 'backup.auto.dayOfWeek': 'Wochentag',
+ 'backup.auto.dayOfMonth': 'Tag des Monats',
+ 'backup.auto.dayOfMonthHint': 'Auf 1–28 beschränkt, um mit allen Monaten kompatibel zu sein',
+ 'backup.auto.scheduleSummary': 'Zeitplan',
+ 'backup.auto.summaryDaily': 'Täglich um {hour}:00',
+ 'backup.auto.summaryWeekly': 'Jeden {day} um {hour}:00',
+ 'backup.auto.summaryMonthly': 'Am {day}. jedes Monats um {hour}:00',
+ 'backup.auto.envLocked': 'Docker',
+ 'backup.auto.envLockedHint': 'Auto-Backup wird über Docker-Umgebungsvariablen konfiguriert. Ändern Sie Ihre docker-compose.yml und starten Sie den Container neu.',
+ 'backup.auto.copyEnv': 'Docker-Umgebungsvariablen kopieren',
+ 'backup.auto.envCopied': 'Docker-Umgebungsvariablen in die Zwischenablage kopiert',
'backup.auto.keepLabel': 'Alte Backups löschen nach',
+ 'backup.dow.sunday': 'So',
+ 'backup.dow.monday': 'Mo',
+ 'backup.dow.tuesday': 'Di',
+ 'backup.dow.wednesday': 'Mi',
+ 'backup.dow.thursday': 'Do',
+ 'backup.dow.friday': 'Fr',
+ 'backup.dow.saturday': 'Sa',
'backup.interval.hourly': 'Stündlich',
'backup.interval.daily': 'Täglich',
'backup.interval.weekly': 'Wöchentlich',
diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts
index 18d79ac..ea8ebe3 100644
--- a/client/src/i18n/translations/en.ts
+++ b/client/src/i18n/translations/en.ts
@@ -139,8 +139,77 @@ const en: Record = {
'settings.temperature': 'Temperature Unit',
'settings.timeFormat': 'Time Format',
'settings.routeCalculation': 'Route Calculation',
+ 'settings.blurBookingCodes': 'Blur Booking Codes',
+ 'settings.notifications': 'Notifications',
+ 'settings.notifyTripInvite': 'Trip invitations',
+ 'settings.notifyBookingChange': 'Booking changes',
+ 'settings.notifyTripReminder': 'Trip reminders',
+ 'settings.notifyVacayInvite': 'Vacay fusion invitations',
+ 'settings.notifyPhotosShared': 'Shared photos (Immich)',
+ 'settings.notifyCollabMessage': 'Chat messages (Collab)',
+ 'settings.notifyPackingTagged': 'Packing list: assignments',
+ 'settings.notifyWebhook': 'Webhook notifications',
+ 'admin.smtp.title': 'Email & Notifications',
+ 'admin.smtp.hint': 'SMTP configuration for email notifications. Optional: Webhook URL for Discord, Slack, etc.',
+ 'admin.smtp.testButton': 'Send test email',
+ 'admin.smtp.testSuccess': 'Test email sent successfully',
+ 'admin.smtp.testFailed': 'Test email failed',
+ 'dayplan.icsTooltip': 'Export calendar (ICS)',
+ 'share.linkTitle': 'Public Link',
+ 'share.linkHint': 'Create a link anyone can use to view this trip without logging in. Read-only — no editing possible.',
+ 'share.createLink': 'Create link',
+ 'share.deleteLink': 'Delete link',
+ 'share.createError': 'Could not create link',
+ 'common.copy': 'Copy',
+ 'common.copied': 'Copied',
+ 'share.permMap': 'Map & Plan',
+ 'share.permBookings': 'Bookings',
+ 'share.permPacking': 'Packing',
+ 'shared.expired': 'Link expired or invalid',
+ 'shared.expiredHint': 'This shared trip link is no longer active.',
+ 'shared.readOnly': 'Read-only shared view',
+ 'shared.tabPlan': 'Plan',
+ 'shared.tabBookings': 'Bookings',
+ 'shared.tabPacking': 'Packing',
+ 'shared.tabBudget': 'Budget',
+ 'shared.tabChat': 'Chat',
+ 'shared.days': 'days',
+ 'shared.places': 'places',
+ 'shared.other': 'Other',
+ 'shared.totalBudget': 'Total Budget',
+ 'shared.messages': 'messages',
+ 'shared.sharedVia': 'Shared via',
+ 'shared.confirmed': 'Confirmed',
+ 'shared.pending': 'Pending',
+ 'share.permBudget': 'Budget',
+ 'share.permCollab': 'Chat',
'settings.on': 'On',
'settings.off': 'Off',
+ 'settings.mcp.title': 'MCP Configuration',
+ 'settings.mcp.endpoint': 'MCP Endpoint',
+ 'settings.mcp.clientConfig': 'Client Configuration',
+ 'settings.mcp.clientConfigHint': 'Replace with an API token from the list below. The path to npx may need to be adjusted for your system (e.g. C:\\PROGRA~1\\nodejs\\npx.cmd on Windows).',
+ 'settings.mcp.copy': 'Copy',
+ 'settings.mcp.copied': 'Copied!',
+ 'settings.mcp.apiTokens': 'API Tokens',
+ 'settings.mcp.createToken': 'Create New Token',
+ 'settings.mcp.noTokens': 'No tokens yet. Create one to connect MCP clients.',
+ 'settings.mcp.tokenCreatedAt': 'Created',
+ 'settings.mcp.tokenUsedAt': 'Used',
+ 'settings.mcp.deleteTokenTitle': 'Delete Token',
+ 'settings.mcp.deleteTokenMessage': 'This token will stop working immediately. Any MCP client using it will lose access.',
+ 'settings.mcp.modal.createTitle': 'Create API Token',
+ 'settings.mcp.modal.tokenName': 'Token Name',
+ 'settings.mcp.modal.tokenNamePlaceholder': 'e.g. Claude Desktop, Work laptop',
+ 'settings.mcp.modal.creating': 'Creating…',
+ 'settings.mcp.modal.create': 'Create Token',
+ 'settings.mcp.modal.createdTitle': 'Token Created',
+ 'settings.mcp.modal.createdWarning': 'This token will only be shown once. Copy and store it now — it cannot be recovered.',
+ 'settings.mcp.modal.done': 'Done',
+ 'settings.mcp.toast.created': 'Token created',
+ 'settings.mcp.toast.createError': 'Failed to create token',
+ 'settings.mcp.toast.deleted': 'Token deleted',
+ 'settings.mcp.toast.deleteError': 'Failed to delete token',
'settings.account': 'Account',
'settings.username': 'Username',
'settings.email': 'Email',
@@ -177,6 +246,14 @@ const en: Record = {
'settings.avatarError': 'Upload failed',
'settings.mfa.title': 'Two-factor authentication (2FA)',
'settings.mfa.description': 'Adds a second step when you sign in with email and password. Use an authenticator app (Google Authenticator, Authy, etc.).',
+ 'settings.mfa.requiredByPolicy': 'Your administrator requires two-factor authentication. Set up an authenticator app below before continuing.',
+ 'settings.mfa.backupTitle': 'Backup codes',
+ 'settings.mfa.backupDescription': 'Use these one-time backup codes if you lose access to your authenticator app.',
+ 'settings.mfa.backupWarning': 'Save these codes now. Each code can only be used once.',
+ 'settings.mfa.backupCopy': 'Copy codes',
+ 'settings.mfa.backupDownload': 'Download TXT',
+ 'settings.mfa.backupPrint': 'Print / PDF',
+ 'settings.mfa.backupCopied': 'Backup codes copied',
'settings.mfa.enabled': '2FA is enabled on your account.',
'settings.mfa.disabled': '2FA is not enabled.',
'settings.mfa.setup': 'Set up authenticator',
@@ -271,6 +348,7 @@ const en: Record = {
'admin.tabs.users': 'Users',
'admin.tabs.categories': 'Categories',
'admin.tabs.backup': 'Backup',
+ 'admin.tabs.audit': 'Audit log',
'admin.stats.users': 'Users',
'admin.stats.trips': 'Trips',
'admin.stats.places': 'Places',
@@ -320,6 +398,8 @@ const en: Record = {
'admin.tabs.settings': 'Settings',
'admin.allowRegistration': 'Allow Registration',
'admin.allowRegistrationHint': 'New users can register themselves',
+ 'admin.requireMfa': 'Require two-factor authentication (2FA)',
+ 'admin.requireMfaHint': 'Users without 2FA must complete setup in Settings before using the app.',
'admin.apiKeys': 'API Keys',
'admin.apiKeysHint': 'Optional. Enables extended place data like photos and weather.',
'admin.mapsKey': 'Google Maps API Key',
@@ -374,8 +454,6 @@ const en: Record = {
'admin.tabs.addons': 'Addons',
'admin.addons.title': 'Addons',
'admin.addons.subtitle': 'Enable or disable features to customize your TREK experience.',
- 'admin.addons.catalog.memories.name': 'Memories',
- 'admin.addons.catalog.memories.description': 'Shared photo albums for each trip',
'admin.addons.catalog.packing.name': 'Packing',
'admin.addons.catalog.packing.description': 'Checklists to prepare your luggage for each trip',
'admin.addons.catalog.budget.name': 'Budget',
@@ -390,14 +468,18 @@ const en: Record = {
'admin.addons.catalog.collab.description': 'Real-time notes, polls, and chat for trip planning',
'admin.addons.catalog.memories.name': 'Photos (Immich)',
'admin.addons.catalog.memories.description': 'Share trip photos via your Immich instance',
+ 'admin.addons.catalog.mcp.name': 'MCP',
+ 'admin.addons.catalog.mcp.description': 'Model Context Protocol for AI assistant integration',
'admin.addons.subtitleBefore': 'Enable or disable features to customize your ',
'admin.addons.subtitleAfter': ' experience.',
'admin.addons.enabled': 'Enabled',
'admin.addons.disabled': 'Disabled',
'admin.addons.type.trip': 'Trip',
'admin.addons.type.global': 'Global',
+ 'admin.addons.type.integration': 'Integration',
'admin.addons.tripHint': 'Available as a tab within each trip',
'admin.addons.globalHint': 'Available as a standalone section in the main navigation',
+ 'admin.addons.integrationHint': 'Backend services and API integrations with no dedicated page',
'admin.addons.toast.updated': 'Addon updated',
'admin.addons.toast.error': 'Failed to update addon',
'admin.addons.noAddons': 'No addons available',
@@ -414,7 +496,33 @@ const en: Record = {
'admin.weather.locationHint': 'Weather is based on the first place with coordinates in each day. If no place is assigned to a day, any place from the place list is used as a reference.',
// GitHub
+ 'admin.tabs.mcpTokens': 'MCP Tokens',
+ 'admin.mcpTokens.title': 'MCP Tokens',
+ 'admin.mcpTokens.subtitle': 'Manage API tokens across all users',
+ 'admin.mcpTokens.owner': 'Owner',
+ 'admin.mcpTokens.tokenName': 'Token Name',
+ 'admin.mcpTokens.created': 'Created',
+ 'admin.mcpTokens.lastUsed': 'Last Used',
+ 'admin.mcpTokens.never': 'Never',
+ 'admin.mcpTokens.empty': 'No MCP tokens have been created yet',
+ 'admin.mcpTokens.deleteTitle': 'Delete Token',
+ 'admin.mcpTokens.deleteMessage': 'This will revoke the token immediately. The user will lose MCP access through this token.',
+ 'admin.mcpTokens.deleteSuccess': 'Token deleted',
+ 'admin.mcpTokens.deleteError': 'Failed to delete token',
+ 'admin.mcpTokens.loadError': 'Failed to load tokens',
'admin.tabs.github': 'GitHub',
+
+ 'admin.audit.subtitle': 'Security-sensitive and administration events (backups, users, MFA, settings).',
+ 'admin.audit.empty': 'No audit entries yet.',
+ 'admin.audit.refresh': 'Refresh',
+ 'admin.audit.loadMore': 'Load more',
+ 'admin.audit.showing': '{count} loaded · {total} total',
+ 'admin.audit.col.time': 'Time',
+ 'admin.audit.col.user': 'User',
+ 'admin.audit.col.action': 'Action',
+ 'admin.audit.col.resource': 'Resource',
+ 'admin.audit.col.ip': 'IP',
+ 'admin.audit.col.details': 'Details',
'admin.github.title': 'Release History',
'admin.github.subtitle': 'Latest updates from {repo}',
'admin.github.latest': 'Latest',
@@ -544,6 +652,10 @@ const en: Record = {
'atlas.markVisited': 'Mark as visited',
'atlas.markVisitedHint': 'Add this country to your visited list',
'atlas.addToBucket': 'Add to bucket list',
+ 'atlas.addPoi': 'Add place',
+ 'atlas.bucketNamePlaceholder': 'Name (country, city, place...)',
+ 'atlas.month': 'Month',
+ 'atlas.year': 'Year',
'atlas.addToBucketHint': 'Save as a place you want to visit',
'atlas.bucketWhen': 'When do you plan to visit?',
'atlas.statsTab': 'Stats',
@@ -607,6 +719,12 @@ const en: Record = {
// Day Plan Sidebar
'dayplan.emptyDay': 'No places planned for this day',
+ 'dayplan.cannotReorderTransport': 'Bookings with a fixed time cannot be reordered',
+ 'dayplan.confirmRemoveTimeTitle': 'Remove time?',
+ 'dayplan.confirmRemoveTimeBody': 'This place has a fixed time ({time}). Moving it will remove the time and allow free sorting.',
+ 'dayplan.confirmRemoveTimeAction': 'Remove time & move',
+ 'dayplan.cannotDropOnTimed': 'Items cannot be placed between time-bound entries',
+ 'dayplan.cannotBreakChronology': 'This would break the chronological order of timed items and bookings',
'dayplan.addNote': 'Add Note',
'dayplan.editNote': 'Edit Note',
'dayplan.noteAdd': 'Add Note',
@@ -632,11 +750,17 @@ const en: Record = {
// Places Sidebar
'places.addPlace': 'Add Place/Activity',
+ 'places.importGpx': 'Import GPX',
+ 'places.gpxImported': '{count} places imported from GPX',
+ 'places.urlResolved': 'Place imported from URL',
+ 'places.gpxError': 'GPX import failed',
'places.assignToDay': 'Add to which day?',
'places.all': 'All',
'places.unplanned': 'Unplanned',
'places.search': 'Search places...',
'places.allCategories': 'All Categories',
+ 'places.categoriesSelected': 'categories',
+ 'places.clearFilter': 'Clear filter',
'places.count': '{count} places',
'places.countSingular': '1 place',
'places.allPlanned': 'All places are planned',
@@ -735,6 +859,8 @@ const en: Record = {
'reservations.type.tour': 'Tour',
'reservations.type.other': 'Other',
'reservations.confirm.delete': 'Are you sure you want to delete the reservation "{name}"?',
+ 'reservations.confirm.deleteTitle': 'Delete booking?',
+ 'reservations.confirm.deleteBody': '"{name}" will be permanently deleted.',
'reservations.toast.updated': 'Reservation updated',
'reservations.toast.removed': 'Reservation deleted',
'reservations.toast.fileUploaded': 'File uploaded',
@@ -792,6 +918,9 @@ const en: Record = {
'budget.paid': 'Paid',
'budget.open': 'Open',
'budget.noMembers': 'No members assigned',
+ 'budget.settlement': 'Settlement',
+ 'budget.settlementInfo': 'Click a member avatar on a budget item to mark them green — this means they paid. The settlement then shows who owes whom and how much.',
+ 'budget.netBalances': 'Net Balances',
// Files
'files.title': 'Files',
@@ -845,6 +974,15 @@ const en: Record = {
// Packing
'packing.title': 'Packing List',
'packing.empty': 'Packing list is empty',
+ 'packing.import': 'Import',
+ 'packing.importTitle': 'Import Packing List',
+ 'packing.importHint': 'One item per line. Format: Category, Name, Weight in g (optional), Bag (optional), checked/unchecked (optional)',
+ 'packing.importPlaceholder': 'Hygiene, Toothbrush\nClothing, T-Shirts, 200\nDocuments, Passport, , Carry-on\nElectronics, Charger, 50, Suitcase, checked',
+ 'packing.importCsv': 'Load CSV/TXT',
+ 'packing.importAction': 'Import {count}',
+ 'packing.importSuccess': '{count} items imported',
+ 'packing.importError': 'Import failed',
+ 'packing.importEmpty': 'No items to import',
'packing.progress': '{packed} of {total} packed ({percent}%)',
'packing.clearChecked': 'Remove {count} checked',
'packing.clearCheckedShort': 'Remove {count}',
@@ -996,7 +1134,27 @@ const en: Record = {
'backup.auto.enable': 'Enable auto-backup',
'backup.auto.enableHint': 'Backups will be created automatically on the chosen schedule',
'backup.auto.interval': 'Interval',
+ 'backup.auto.hour': 'Run at hour',
+ 'backup.auto.hourHint': 'Server local time ({format} format)',
+ 'backup.auto.dayOfWeek': 'Day of week',
+ 'backup.auto.dayOfMonth': 'Day of month',
+ 'backup.auto.dayOfMonthHint': 'Limited to 1–28 for compatibility with all months',
+ 'backup.auto.scheduleSummary': 'Schedule',
+ 'backup.auto.summaryDaily': 'Every day at {hour}:00',
+ 'backup.auto.summaryWeekly': 'Every {day} at {hour}:00',
+ 'backup.auto.summaryMonthly': 'Day {day} of every month at {hour}:00',
+ 'backup.auto.envLocked': 'Docker',
+ 'backup.auto.envLockedHint': 'Auto-backup is configured via Docker environment variables. To change these settings, update your docker-compose.yml and restart the container.',
+ 'backup.auto.copyEnv': 'Copy Docker env vars',
+ 'backup.auto.envCopied': 'Docker env vars copied to clipboard',
'backup.auto.keepLabel': 'Delete old backups after',
+ 'backup.dow.sunday': 'Sun',
+ 'backup.dow.monday': 'Mon',
+ 'backup.dow.tuesday': 'Tue',
+ 'backup.dow.wednesday': 'Wed',
+ 'backup.dow.thursday': 'Thu',
+ 'backup.dow.friday': 'Fri',
+ 'backup.dow.saturday': 'Sat',
'backup.interval.hourly': 'Hourly',
'backup.interval.daily': 'Daily',
'backup.interval.weekly': 'Weekly',
diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts
index 3cb293f..f1b0e1e 100644
--- a/client/src/i18n/translations/es.ts
+++ b/client/src/i18n/translations/es.ts
@@ -140,8 +140,77 @@ const es: Record = {
'settings.temperature': 'Unidad de temperatura',
'settings.timeFormat': 'Formato de hora',
'settings.routeCalculation': 'Cálculo de ruta',
+ 'settings.blurBookingCodes': 'Difuminar códigos de reserva',
+ 'settings.notifications': 'Notificaciones',
+ 'settings.notifyTripInvite': 'Invitaciones de viaje',
+ 'settings.notifyBookingChange': 'Cambios en reservas',
+ 'settings.notifyTripReminder': 'Recordatorios de viaje',
+ 'settings.notifyVacayInvite': 'Invitaciones de fusión Vacay',
+ 'settings.notifyPhotosShared': 'Fotos compartidas (Immich)',
+ 'settings.notifyCollabMessage': 'Mensajes de chat (Collab)',
+ 'settings.notifyPackingTagged': 'Lista de equipaje: asignaciones',
+ 'settings.notifyWebhook': 'Notificaciones webhook',
+ 'admin.smtp.title': 'Correo y notificaciones',
+ 'admin.smtp.hint': 'Configuración SMTP para notificaciones por correo. Opcional: URL webhook para Discord, Slack, etc.',
+ 'admin.smtp.testButton': 'Enviar correo de prueba',
+ 'admin.smtp.testSuccess': 'Correo de prueba enviado correctamente',
+ 'admin.smtp.testFailed': 'Error al enviar correo de prueba',
+ 'dayplan.icsTooltip': 'Exportar calendario (ICS)',
+ 'share.linkTitle': 'Enlace público',
+ 'share.linkHint': 'Crea un enlace que cualquiera puede usar para ver este viaje sin iniciar sesión. Solo lectura — no se puede editar.',
+ 'share.createLink': 'Crear enlace',
+ 'share.deleteLink': 'Eliminar enlace',
+ 'share.createError': 'No se pudo crear el enlace',
+ 'common.copy': 'Copiar',
+ 'common.copied': 'Copiado',
+ 'share.permMap': 'Mapa y plan',
+ 'share.permBookings': 'Reservas',
+ 'share.permPacking': 'Equipaje',
+ 'shared.expired': 'Enlace expirado o inválido',
+ 'shared.expiredHint': 'Este enlace de viaje compartido ya no está activo.',
+ 'shared.readOnly': 'Vista de solo lectura',
+ 'shared.tabPlan': 'Plan',
+ 'shared.tabBookings': 'Reservas',
+ 'shared.tabPacking': 'Equipaje',
+ 'shared.tabBudget': 'Presupuesto',
+ 'shared.tabChat': 'Chat',
+ 'shared.days': 'días',
+ 'shared.places': 'lugares',
+ 'shared.other': 'Otro',
+ 'shared.totalBudget': 'Presupuesto total',
+ 'shared.messages': 'mensajes',
+ 'shared.sharedVia': 'Compartido vía',
+ 'shared.confirmed': 'Confirmado',
+ 'shared.pending': 'Pendiente',
+ 'share.permBudget': 'Presupuesto',
+ 'share.permCollab': 'Chat',
'settings.on': 'Activado',
'settings.off': 'Desactivado',
+ 'settings.mcp.title': 'Configuración MCP',
+ 'settings.mcp.endpoint': 'Endpoint MCP',
+ 'settings.mcp.clientConfig': 'Configuración del cliente',
+ 'settings.mcp.clientConfigHint': 'Reemplaza con un token de la lista de abajo. Es posible que debas ajustar la ruta de npx según tu sistema (p. ej. C:\\PROGRA~1\\nodejs\\npx.cmd en Windows).',
+ 'settings.mcp.copy': 'Copiar',
+ 'settings.mcp.copied': '¡Copiado!',
+ 'settings.mcp.apiTokens': 'Tokens de API',
+ 'settings.mcp.createToken': 'Crear nuevo token',
+ 'settings.mcp.noTokens': 'Sin tokens aún. Crea uno para conectar clientes MCP.',
+ 'settings.mcp.tokenCreatedAt': 'Creado',
+ 'settings.mcp.tokenUsedAt': 'Usado',
+ 'settings.mcp.deleteTokenTitle': 'Eliminar token',
+ 'settings.mcp.deleteTokenMessage': 'Este token dejará de funcionar de inmediato. Cualquier cliente MCP que lo use perderá el acceso.',
+ 'settings.mcp.modal.createTitle': 'Crear token de API',
+ 'settings.mcp.modal.tokenName': 'Nombre del token',
+ 'settings.mcp.modal.tokenNamePlaceholder': 'p. ej. Claude Desktop, Portátil de trabajo',
+ 'settings.mcp.modal.creating': 'Creando…',
+ 'settings.mcp.modal.create': 'Crear token',
+ 'settings.mcp.modal.createdTitle': 'Token creado',
+ 'settings.mcp.modal.createdWarning': 'Este token solo se mostrará una vez. Cópialo y guárdalo ahora — no se podrá recuperar.',
+ 'settings.mcp.modal.done': 'Listo',
+ 'settings.mcp.toast.created': 'Token creado',
+ 'settings.mcp.toast.createError': 'Error al crear el token',
+ 'settings.mcp.toast.deleted': 'Token eliminado',
+ 'settings.mcp.toast.deleteError': 'Error al eliminar el token',
'settings.account': 'Cuenta',
'settings.username': 'Usuario',
'settings.email': 'Correo',
@@ -167,6 +236,14 @@ const es: Record = {
'settings.saveProfile': 'Guardar perfil',
'settings.mfa.title': 'Autenticación de dos factores (2FA)',
'settings.mfa.description': 'Añade un segundo paso al iniciar sesión. Usa una app de autenticación (Google Authenticator, Authy, etc.).',
+ 'settings.mfa.requiredByPolicy': 'Tu administrador exige autenticación en dos factores. Configura una app de autenticación abajo antes de continuar.',
+ 'settings.mfa.backupTitle': 'Códigos de respaldo',
+ 'settings.mfa.backupDescription': 'Usa estos códigos de un solo uso si pierdes acceso a tu app autenticadora.',
+ 'settings.mfa.backupWarning': 'Guárdalos ahora. Cada código solo se puede usar una vez.',
+ 'settings.mfa.backupCopy': 'Copiar códigos',
+ 'settings.mfa.backupDownload': 'Descargar TXT',
+ 'settings.mfa.backupPrint': 'Imprimir / PDF',
+ 'settings.mfa.backupCopied': 'Códigos de respaldo copiados',
'settings.mfa.enabled': '2FA está activado en tu cuenta.',
'settings.mfa.disabled': '2FA no está activado.',
'settings.mfa.setup': 'Configurar autenticador',
@@ -269,6 +346,7 @@ const es: Record = {
'admin.tabs.users': 'Usuarios',
'admin.tabs.categories': 'Categorías',
'admin.tabs.backup': 'Copia de seguridad',
+ 'admin.tabs.audit': 'Registro de auditoría',
'admin.stats.users': 'Usuarios',
'admin.stats.trips': 'Viajes',
'admin.stats.places': 'Lugares',
@@ -318,6 +396,8 @@ const es: Record = {
'admin.tabs.settings': 'Ajustes',
'admin.allowRegistration': 'Permitir el registro',
'admin.allowRegistrationHint': 'Los nuevos usuarios pueden registrarse por sí mismos',
+ 'admin.requireMfa': 'Exigir autenticación en dos factores (2FA)',
+ 'admin.requireMfaHint': 'Los usuarios sin 2FA deben completar la configuración en Ajustes antes de usar la aplicación.',
'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',
@@ -375,8 +455,10 @@ const es: Record = {
'admin.addons.disabled': 'Desactivado',
'admin.addons.type.trip': 'Viaje',
'admin.addons.type.global': 'Global',
+ 'admin.addons.type.integration': 'Integración',
'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.integrationHint': 'Servicios backend e integraciones de API sin página dedicada',
'admin.addons.toast.updated': 'Complemento actualizado',
'admin.addons.toast.error': 'No se pudo actualizar el complemento',
'admin.addons.noAddons': 'No hay complementos disponibles',
@@ -391,8 +473,37 @@ const es: Record = {
'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.',
+ // MCP Tokens
+ 'admin.tabs.mcpTokens': 'Tokens MCP',
+ 'admin.mcpTokens.title': 'Tokens MCP',
+ 'admin.mcpTokens.subtitle': 'Gestionar tokens de API de todos los usuarios',
+ 'admin.mcpTokens.owner': 'Propietario',
+ 'admin.mcpTokens.tokenName': 'Nombre del token',
+ 'admin.mcpTokens.created': 'Creado',
+ 'admin.mcpTokens.lastUsed': 'Último uso',
+ 'admin.mcpTokens.never': 'Nunca',
+ 'admin.mcpTokens.empty': 'Aún no se han creado tokens MCP',
+ 'admin.mcpTokens.deleteTitle': 'Eliminar token',
+ 'admin.mcpTokens.deleteMessage': 'Este token se revocará inmediatamente. El usuario perderá el acceso MCP a través de este token.',
+ 'admin.mcpTokens.deleteSuccess': 'Token eliminado',
+ 'admin.mcpTokens.deleteError': 'No se pudo eliminar el token',
+ 'admin.mcpTokens.loadError': 'No se pudieron cargar los tokens',
+
// GitHub
'admin.tabs.github': 'GitHub',
+
+ 'admin.audit.subtitle': 'Eventos sensibles de seguridad y administración (copias de seguridad, usuarios, MFA, ajustes).',
+ 'admin.audit.empty': 'Aún no hay entradas de auditoría.',
+ 'admin.audit.refresh': 'Actualizar',
+ 'admin.audit.loadMore': 'Cargar más',
+ 'admin.audit.showing': '{count} cargados · {total} en total',
+ 'admin.audit.col.time': 'Fecha y hora',
+ 'admin.audit.col.user': 'Usuario',
+ 'admin.audit.col.action': 'Acción',
+ 'admin.audit.col.resource': 'Recurso',
+ 'admin.audit.col.ip': 'IP',
+ 'admin.audit.col.details': 'Detalles',
+
'admin.github.title': 'Historial de versiones',
'admin.github.subtitle': 'Últimas novedades de {repo}',
'admin.github.latest': 'Última',
@@ -455,6 +566,14 @@ const es: Record = {
'vacay.carriedOver': 'de {year}',
'vacay.blockWeekends': 'Bloquear fines de semana',
'vacay.blockWeekendsHint': 'Impide marcar vacaciones en sábados y domingos',
+ 'vacay.weekendDays': 'Días de fin de semana',
+ 'vacay.mon': 'Lun',
+ 'vacay.tue': 'Mar',
+ 'vacay.wed': 'Mié',
+ 'vacay.thu': 'Jue',
+ 'vacay.fri': 'Vie',
+ 'vacay.sat': 'Sáb',
+ 'vacay.sun': 'Dom',
'vacay.publicHolidays': 'Festivos',
'vacay.publicHolidaysHint': 'Marcar festivos en el calendario',
'vacay.selectCountry': 'Seleccionar país',
@@ -548,6 +667,10 @@ const es: Record = {
'atlas.markVisited': 'Marcar como visitado',
'atlas.markVisitedHint': 'Añadir este país a tu lista de visitados',
'atlas.addToBucket': 'Añadir a lista de deseos',
+ 'atlas.addPoi': 'Añadir lugar',
+ 'atlas.bucketNamePlaceholder': 'Nombre (país, ciudad, lugar…)',
+ 'atlas.month': 'Mes',
+ 'atlas.year': 'Año',
'atlas.addToBucketHint': 'Guardar como lugar que quieres visitar',
'atlas.bucketWhen': '¿Cuándo planeas visitarlo?',
@@ -597,14 +720,26 @@ const es: Record = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Exportar plan diario como PDF',
'dayplan.pdfError': 'No se pudo exportar el PDF',
+ 'dayplan.cannotReorderTransport': 'Las reservas con hora fija no se pueden reordenar',
+ 'dayplan.confirmRemoveTimeTitle': '¿Eliminar hora?',
+ 'dayplan.confirmRemoveTimeBody': 'Este lugar tiene una hora fija ({time}). Al moverlo se eliminará la hora y se permitirá el orden libre.',
+ 'dayplan.confirmRemoveTimeAction': 'Eliminar hora y mover',
+ 'dayplan.cannotDropOnTimed': 'No se pueden colocar elementos entre entradas con hora fija',
+ 'dayplan.cannotBreakChronology': 'Esto rompería el orden cronológico de los elementos y reservas programados',
// Places Sidebar
'places.addPlace': 'Añadir lugar/actividad',
+ 'places.importGpx': 'Importar GPX',
+ 'places.gpxImported': '{count} lugares importados desde GPX',
+ 'places.gpxError': 'Error al importar GPX',
+ 'places.urlResolved': 'Lugar importado desde URL',
'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.categoriesSelected': 'categorías',
+ 'places.clearFilter': 'Borrar filtro',
'places.count': '{count} lugares',
'places.countSingular': '1 lugar',
'places.allPlanned': 'Todos los lugares están planificados',
@@ -687,6 +822,8 @@ const es: Record = {
'reservations.type.tour': 'Tour',
'reservations.type.other': 'Otro',
'reservations.confirm.delete': '¿Seguro que quieres eliminar la reserva "{name}"?',
+ 'reservations.confirm.deleteTitle': '¿Eliminar reserva?',
+ 'reservations.confirm.deleteBody': '« {name} » se eliminará permanentemente.',
'reservations.toast.updated': 'Reserva actualizada',
'reservations.toast.removed': 'Reserva eliminada',
'reservations.toast.fileUploaded': 'Archivo subido',
@@ -744,6 +881,9 @@ const es: Record = {
'budget.paid': 'Pagado',
'budget.open': 'Abrir',
'budget.noMembers': 'No hay miembros asignados',
+ 'budget.settlement': 'Liquidación',
+ 'budget.settlementInfo': 'Haz clic en el avatar de un miembro en una partida del presupuesto para marcarlo en verde — esto significa que ha pagado. La liquidación muestra quién debe cuánto a quién.',
+ 'budget.netBalances': 'Saldos netos',
// Files
'files.title': 'Archivos',
@@ -775,6 +915,15 @@ const es: Record = {
// Packing
'packing.title': 'Lista de equipaje',
'packing.empty': 'La lista de equipaje está vacía',
+ 'packing.import': 'Importar',
+ 'packing.importTitle': 'Importar lista de equipaje',
+ 'packing.importHint': 'Un elemento por línea. Categoría y cantidad opcionales separadas por coma, punto y coma o tabulación: Nombre, Categoría, Cantidad',
+ 'packing.importPlaceholder': 'Cepillo de dientes\nProtector solar, Higiene\nCamisetas, Ropa, 5\nPasaporte, Documentos',
+ 'packing.importCsv': 'Cargar CSV/TXT',
+ 'packing.importAction': 'Importar {count}',
+ 'packing.importSuccess': '{count} elementos importados',
+ 'packing.importError': 'Error al importar',
+ 'packing.importEmpty': 'Sin elementos para importar',
'packing.progress': '{packed} de {total} preparados ({percent}%)',
'packing.clearChecked': 'Eliminar {count} marcados',
'packing.clearCheckedShort': 'Eliminar {count}',
@@ -926,7 +1075,27 @@ const es: Record = {
'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.hour': 'Ejecutar a la hora',
+ 'backup.auto.hourHint': 'Hora local del servidor (formato {format})',
+ 'backup.auto.dayOfWeek': 'Día de la semana',
+ 'backup.auto.dayOfMonth': 'Día del mes',
+ 'backup.auto.dayOfMonthHint': 'Limitado a 1–28 para compatibilidad con todos los meses',
+ 'backup.auto.scheduleSummary': 'Programación',
+ 'backup.auto.summaryDaily': 'Todos los días a las {hour}:00',
+ 'backup.auto.summaryWeekly': 'Cada {day} a las {hour}:00',
+ 'backup.auto.summaryMonthly': 'El día {day} de cada mes a las {hour}:00',
+ 'backup.auto.envLocked': 'Docker',
+ 'backup.auto.envLockedHint': 'La copia automática está configurada mediante variables de entorno Docker. Para cambiar estos ajustes, actualiza tu docker-compose.yml y reinicia el contenedor.',
+ 'backup.auto.copyEnv': 'Copiar variables de entorno Docker',
+ 'backup.auto.envCopied': 'Variables de entorno Docker copiadas al portapapeles',
'backup.auto.keepLabel': 'Eliminar copias antiguas después de',
+ 'backup.dow.sunday': 'Dom',
+ 'backup.dow.monday': 'Lun',
+ 'backup.dow.tuesday': 'Mar',
+ 'backup.dow.wednesday': 'Mié',
+ 'backup.dow.thursday': 'Jue',
+ 'backup.dow.friday': 'Vie',
+ 'backup.dow.saturday': 'Sáb',
'backup.interval.hourly': 'Cada hora',
'backup.interval.daily': 'Diaria',
'backup.interval.weekly': 'Semanal',
@@ -948,6 +1117,8 @@ const es: Record = {
'photos.uploadN': 'Subida de {n} foto(s)',
'admin.addons.catalog.memories.name': 'Fotos (Immich)',
'admin.addons.catalog.memories.description': 'Comparte fotos de viaje a través de tu instancia de Immich',
+ 'admin.addons.catalog.mcp.name': 'MCP',
+ 'admin.addons.catalog.mcp.description': 'Protocolo de contexto de modelo para integración con asistentes de IA',
'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',
@@ -1091,6 +1262,19 @@ const es: Record = {
'memories.oldest': 'Más antiguas',
'memories.newest': 'Más recientes',
'memories.allLocations': 'Todas las ubicaciones',
+ 'memories.addPhotos': 'Añadir fotos',
+ 'memories.selectPhotos': 'Seleccionar fotos de Immich',
+ 'memories.selectHint': 'Toca las fotos para seleccionarlas.',
+ 'memories.selected': 'seleccionado(s)',
+ 'memories.addSelected': 'Añadir {count} fotos',
+ 'memories.alreadyAdded': 'Añadido',
+ 'memories.private': 'Privado',
+ 'memories.stopSharing': 'Dejar de compartir',
+ 'memories.tripDates': 'Fechas del viaje',
+ 'memories.allPhotos': 'Todas las fotos',
+ 'memories.confirmShareTitle': '¿Compartir con los miembros del viaje?',
+ 'memories.confirmShareHint': '{count} fotos serán visibles para todos los miembros de este viaje. Puedes hacer fotos individuales privadas más tarde.',
+ 'memories.confirmShareButton': 'Compartir fotos',
// Collab Addon
'collab.tabs.chat': 'Mensajes',
diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts
index 28a7462..6ff7793 100644
--- a/client/src/i18n/translations/fr.ts
+++ b/client/src/i18n/translations/fr.ts
@@ -139,8 +139,77 @@ const fr: Record = {
'settings.temperature': 'Unité de température',
'settings.timeFormat': 'Format de l\'heure',
'settings.routeCalculation': 'Calcul d\'itinéraire',
+ 'settings.blurBookingCodes': 'Masquer les codes de réservation',
+ 'settings.notifications': 'Notifications',
+ 'settings.notifyTripInvite': 'Invitations de voyage',
+ 'settings.notifyBookingChange': 'Modifications de réservation',
+ 'settings.notifyTripReminder': 'Rappels de voyage',
+ 'settings.notifyVacayInvite': 'Invitations de fusion Vacay',
+ 'settings.notifyPhotosShared': 'Photos partagées (Immich)',
+ 'settings.notifyCollabMessage': 'Messages de chat (Collab)',
+ 'settings.notifyPackingTagged': 'Liste de bagages : attributions',
+ 'settings.notifyWebhook': 'Notifications webhook',
+ 'admin.smtp.title': 'E-mail et notifications',
+ 'admin.smtp.hint': 'Configuration SMTP pour les notifications par e-mail. Optionnel : URL webhook pour Discord, Slack, etc.',
+ 'admin.smtp.testButton': 'Envoyer un e-mail de test',
+ 'admin.smtp.testSuccess': 'E-mail de test envoyé avec succès',
+ 'admin.smtp.testFailed': 'Échec de l\'e-mail de test',
+ 'dayplan.icsTooltip': 'Exporter le calendrier (ICS)',
+ 'share.linkTitle': 'Lien public',
+ 'share.linkHint': 'Créez un lien que n\'importe qui peut utiliser pour consulter ce voyage sans se connecter. Lecture seule — aucune modification possible.',
+ 'share.createLink': 'Créer un lien',
+ 'share.deleteLink': 'Supprimer le lien',
+ 'share.createError': 'Impossible de créer le lien',
+ 'common.copy': 'Copier',
+ 'common.copied': 'Copié',
+ 'share.permMap': 'Carte et plan',
+ 'share.permBookings': 'Réservations',
+ 'share.permPacking': 'Bagages',
+ 'shared.expired': 'Lien expiré ou invalide',
+ 'shared.expiredHint': 'Ce lien de partage n\'est plus actif.',
+ 'shared.readOnly': 'Vue en lecture seule',
+ 'shared.tabPlan': 'Plan',
+ 'shared.tabBookings': 'Réservations',
+ 'shared.tabPacking': 'Bagages',
+ 'shared.tabBudget': 'Budget',
+ 'shared.tabChat': 'Chat',
+ 'shared.days': 'jours',
+ 'shared.places': 'lieux',
+ 'shared.other': 'Autre',
+ 'shared.totalBudget': 'Budget total',
+ 'shared.messages': 'messages',
+ 'shared.sharedVia': 'Partagé via',
+ 'shared.confirmed': 'Confirmé',
+ 'shared.pending': 'En attente',
+ 'share.permBudget': 'Budget',
+ 'share.permCollab': 'Chat',
'settings.on': 'Activé',
'settings.off': 'Désactivé',
+ 'settings.mcp.title': 'Configuration MCP',
+ 'settings.mcp.endpoint': 'Point de terminaison MCP',
+ 'settings.mcp.clientConfig': 'Configuration du client',
+ 'settings.mcp.clientConfigHint': 'Remplacez par un token API de la liste ci-dessous. Le chemin vers npx devra peut-être être ajusté selon votre système (ex. C:\\PROGRA~1\\nodejs\\npx.cmd sous Windows).',
+ 'settings.mcp.copy': 'Copier',
+ 'settings.mcp.copied': 'Copié !',
+ 'settings.mcp.apiTokens': 'Tokens API',
+ 'settings.mcp.createToken': 'Créer un token',
+ 'settings.mcp.noTokens': 'Aucun token pour l\'instant. Créez-en un pour connecter des clients MCP.',
+ 'settings.mcp.tokenCreatedAt': 'Créé',
+ 'settings.mcp.tokenUsedAt': 'Utilisé',
+ 'settings.mcp.deleteTokenTitle': 'Supprimer le token',
+ 'settings.mcp.deleteTokenMessage': 'Ce token cessera de fonctionner immédiatement. Tout client MCP l\'utilisant perdra l\'accès.',
+ 'settings.mcp.modal.createTitle': 'Créer un token API',
+ 'settings.mcp.modal.tokenName': 'Nom du token',
+ 'settings.mcp.modal.tokenNamePlaceholder': 'ex. Claude Desktop, Ordinateur pro',
+ 'settings.mcp.modal.creating': 'Création…',
+ 'settings.mcp.modal.create': 'Créer le token',
+ 'settings.mcp.modal.createdTitle': 'Token créé',
+ 'settings.mcp.modal.createdWarning': 'Ce token ne sera affiché qu\'une seule fois. Copiez-le et conservez-le maintenant — il ne pourra pas être récupéré.',
+ 'settings.mcp.modal.done': 'Terminé',
+ 'settings.mcp.toast.created': 'Token créé',
+ 'settings.mcp.toast.createError': 'Impossible de créer le token',
+ 'settings.mcp.toast.deleted': 'Token supprimé',
+ 'settings.mcp.toast.deleteError': 'Impossible de supprimer le token',
'settings.account': 'Compte',
'settings.username': 'Nom d\'utilisateur',
'settings.email': 'E-mail',
@@ -168,6 +237,14 @@ const fr: Record = {
'settings.saveProfile': 'Enregistrer le profil',
'settings.mfa.title': 'Authentification à deux facteurs (2FA)',
'settings.mfa.description': 'Ajoute une étape supplémentaire lors de la connexion. Utilisez une application d\'authentification (Google Authenticator, Authy, etc.).',
+ 'settings.mfa.requiredByPolicy': 'Votre administrateur exige l\'authentification à deux facteurs. Configurez une application d\'authentification ci-dessous avant de continuer.',
+ 'settings.mfa.backupTitle': 'Codes de secours',
+ 'settings.mfa.backupDescription': 'Utilisez ces codes à usage unique si vous perdez l\'accès à votre application d\'authentification.',
+ 'settings.mfa.backupWarning': 'Enregistrez ces codes maintenant. Chaque code n\'est utilisable qu\'une seule fois.',
+ 'settings.mfa.backupCopy': 'Copier les codes',
+ 'settings.mfa.backupDownload': 'Télécharger TXT',
+ 'settings.mfa.backupPrint': 'Imprimer / PDF',
+ 'settings.mfa.backupCopied': 'Codes de secours copiés',
'settings.mfa.enabled': '2FA est activé sur votre compte.',
'settings.mfa.disabled': '2FA n\'est pas activé.',
'settings.mfa.setup': 'Configurer l\'authentificateur',
@@ -320,6 +397,8 @@ const fr: Record = {
'admin.tabs.settings': 'Paramètres',
'admin.allowRegistration': 'Autoriser les inscriptions',
'admin.allowRegistrationHint': 'Les nouveaux utilisateurs peuvent s\'inscrire eux-mêmes',
+ 'admin.requireMfa': 'Exiger l\'authentification à deux facteurs (2FA)',
+ 'admin.requireMfaHint': 'Les utilisateurs sans 2FA doivent terminer la configuration dans Paramètres avant d\'utiliser l\'application.',
'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',
@@ -375,6 +454,8 @@ const fr: Record = {
'admin.addons.subtitle': 'Activez ou désactivez des fonctionnalités pour personnaliser votre expérience TREK.',
'admin.addons.catalog.memories.name': 'Photos (Immich)',
'admin.addons.catalog.memories.description': 'Partagez vos photos de voyage via votre instance Immich',
+ 'admin.addons.catalog.mcp.name': 'MCP',
+ 'admin.addons.catalog.mcp.description': 'Protocole de contexte de modèle pour l\'intégration d\'assistants IA',
'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',
@@ -393,8 +474,10 @@ const fr: Record = {
'admin.addons.disabled': 'Désactivé',
'admin.addons.type.trip': 'Voyage',
'admin.addons.type.global': 'Global',
+ 'admin.addons.type.integration': 'Intégration',
'admin.addons.tripHint': 'Disponible comme onglet dans chaque voyage',
'admin.addons.globalHint': 'Disponible comme section autonome dans la navigation principale',
+ 'admin.addons.integrationHint': 'Services backend et intégrations API sans page dédiée',
'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',
@@ -410,6 +493,36 @@ const fr: Record = {
'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 attribué à un jour, un lieu de la liste est utilisé comme référence.',
+ 'admin.tabs.audit': 'Journal d\'audit',
+
+ 'admin.audit.subtitle': 'Événements sensibles de sécurité et d\'administration (sauvegardes, utilisateurs, 2FA, paramètres).',
+ 'admin.audit.empty': 'Aucune entrée d\'audit.',
+ 'admin.audit.refresh': 'Actualiser',
+ 'admin.audit.loadMore': 'Charger plus',
+ 'admin.audit.showing': '{count} chargées · {total} au total',
+ 'admin.audit.col.time': 'Heure',
+ 'admin.audit.col.user': 'Utilisateur',
+ 'admin.audit.col.action': 'Action',
+ 'admin.audit.col.resource': 'Ressource',
+ 'admin.audit.col.ip': 'IP',
+ 'admin.audit.col.details': 'Détails',
+
+ // MCP Tokens
+ 'admin.tabs.mcpTokens': 'Tokens MCP',
+ 'admin.mcpTokens.title': 'Tokens MCP',
+ 'admin.mcpTokens.subtitle': 'Gérer les tokens API de tous les utilisateurs',
+ 'admin.mcpTokens.owner': 'Propriétaire',
+ 'admin.mcpTokens.tokenName': 'Nom du token',
+ 'admin.mcpTokens.created': 'Créé',
+ 'admin.mcpTokens.lastUsed': 'Dernière utilisation',
+ 'admin.mcpTokens.never': 'Jamais',
+ 'admin.mcpTokens.empty': 'Aucun token MCP n\'a encore été créé',
+ 'admin.mcpTokens.deleteTitle': 'Supprimer le token',
+ 'admin.mcpTokens.deleteMessage': 'Ce token sera révoqué immédiatement. L\'utilisateur perdra l\'accès MCP via ce token.',
+ 'admin.mcpTokens.deleteSuccess': 'Token supprimé',
+ 'admin.mcpTokens.deleteError': 'Impossible de supprimer le token',
+ 'admin.mcpTokens.loadError': 'Impossible de charger les tokens',
+
// GitHub
'admin.tabs.github': 'GitHub',
'admin.github.title': 'Historique des versions',
@@ -577,6 +690,10 @@ const fr: Record = {
'atlas.markVisited': 'Marquer comme visité',
'atlas.markVisitedHint': 'Ajouter ce pays à votre liste de visités',
'atlas.addToBucket': 'Ajouter à la bucket list',
+ 'atlas.addPoi': 'Ajouter un lieu',
+ 'atlas.bucketNamePlaceholder': 'Nom (pays, ville, lieu…)',
+ 'atlas.month': 'Mois',
+ 'atlas.year': 'Année',
'atlas.addToBucketHint': 'Sauvegarder comme lieu à visiter',
'atlas.bucketWhen': 'Quand prévoyez-vous d\'y aller ?',
@@ -626,14 +743,26 @@ const fr: Record = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Exporter le plan du jour en PDF',
'dayplan.pdfError': 'Échec de l\'export PDF',
+ 'dayplan.cannotReorderTransport': 'Les réservations avec une heure fixe ne peuvent pas être réorganisées',
+ 'dayplan.confirmRemoveTimeTitle': 'Supprimer l\'heure ?',
+ 'dayplan.confirmRemoveTimeBody': 'Ce lieu a une heure fixe ({time}). Le déplacer supprimera l\'heure et permettra un tri libre.',
+ 'dayplan.confirmRemoveTimeAction': 'Supprimer l\'heure et déplacer',
+ 'dayplan.cannotDropOnTimed': 'Les éléments ne peuvent pas être placés entre des entrées à heure fixe',
+ 'dayplan.cannotBreakChronology': 'Cela briserait l\'ordre chronologique des éléments et réservations planifiés',
// Places Sidebar
- 'places.addPlace': 'Ajouter un lieu ou une activité',
+ 'places.addPlace': 'Ajouter un lieu/activité',
+ 'places.importGpx': 'Importer GPX',
+ 'places.gpxImported': '{count} lieux importés depuis GPX',
+ 'places.gpxError': 'L\'import GPX a échoué',
+ 'places.urlResolved': 'Lieu importé depuis l\'URL',
'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.categoriesSelected': 'catégories',
+ 'places.clearFilter': 'Effacer le filtre',
'places.count': '{count} lieux',
'places.countSingular': '1 lieu',
'places.allPlanned': 'Tous les lieux sont planifiés',
@@ -732,6 +861,8 @@ const fr: Record = {
'reservations.type.tour': 'Visite',
'reservations.type.other': 'Autre',
'reservations.confirm.delete': 'Voulez-vous vraiment supprimer la réservation « {name} » ?',
+ 'reservations.confirm.deleteTitle': 'Supprimer la réservation ?',
+ 'reservations.confirm.deleteBody': '« {name} » sera définitivement supprimé.',
'reservations.toast.updated': 'Réservation mise à jour',
'reservations.toast.removed': 'Réservation supprimée',
'reservations.toast.fileUploaded': 'Fichier importé',
@@ -788,7 +919,10 @@ const fr: Record = {
'budget.perPerson': 'Par personne',
'budget.paid': 'Payé',
'budget.open': 'Ouvert',
- 'budget.noMembers': 'Aucun membre attribué',
+ 'budget.noMembers': 'Aucun membre assigné',
+ 'budget.settlement': 'Règlement',
+ 'budget.settlementInfo': 'Cliquez sur l\'avatar d\'un membre sur un poste budgétaire pour le marquer en vert — cela signifie qu\'il a payé. Le règlement indique ensuite qui doit combien à qui.',
+ 'budget.netBalances': 'Soldes nets',
// Files
'files.title': 'Fichiers',
@@ -842,6 +976,15 @@ const fr: Record = {
// Packing
'packing.title': 'Liste de bagages',
'packing.empty': 'La liste de bagages est vide',
+ 'packing.import': 'Importer',
+ 'packing.importTitle': 'Importer la liste',
+ 'packing.importHint': 'Un élément par ligne. Catégorie et quantité optionnelles séparées par virgule, point-virgule ou tabulation : Nom, Catégorie, Quantité',
+ 'packing.importPlaceholder': 'Brosse à dents\nCrème solaire, Hygiène\nT-Shirts, Vêtements, 5\nPasseport, Documents',
+ 'packing.importCsv': 'Charger CSV/TXT',
+ 'packing.importAction': 'Importer {count}',
+ 'packing.importSuccess': '{count} éléments importés',
+ 'packing.importError': 'Échec de l\'import',
+ 'packing.importEmpty': 'Aucun élément à importer',
'packing.progress': '{packed} sur {total} emballés ({percent} %)',
'packing.clearChecked': 'Supprimer {count} cochés',
'packing.clearCheckedShort': 'Supprimer {count}',
@@ -993,7 +1136,27 @@ const fr: Record = {
'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.hour': 'Exécuter à l\'heure',
+ 'backup.auto.hourHint': 'Heure locale du serveur (format {format})',
+ 'backup.auto.dayOfWeek': 'Jour de la semaine',
+ 'backup.auto.dayOfMonth': 'Jour du mois',
+ 'backup.auto.dayOfMonthHint': 'Limité à 1–28 pour la compatibilité avec tous les mois',
+ 'backup.auto.scheduleSummary': 'Planification',
+ 'backup.auto.summaryDaily': 'Tous les jours à {hour}h00',
+ 'backup.auto.summaryWeekly': 'Chaque {day} à {hour}h00',
+ 'backup.auto.summaryMonthly': 'Le {day} de chaque mois à {hour}h00',
+ 'backup.auto.envLocked': 'Docker',
+ 'backup.auto.envLockedHint': 'La sauvegarde automatique est configurée via les variables d\'environnement Docker. Pour modifier ces paramètres, mettez à jour votre docker-compose.yml et redémarrez le conteneur.',
+ 'backup.auto.copyEnv': 'Copier les variables d\'env Docker',
+ 'backup.auto.envCopied': 'Variables d\'env Docker copiées dans le presse-papiers',
'backup.auto.keepLabel': 'Supprimer les anciennes sauvegardes après',
+ 'backup.dow.sunday': 'Dim',
+ 'backup.dow.monday': 'Lun',
+ 'backup.dow.tuesday': 'Mar',
+ 'backup.dow.wednesday': 'Mer',
+ 'backup.dow.thursday': 'Jeu',
+ 'backup.dow.friday': 'Ven',
+ 'backup.dow.saturday': 'Sam',
'backup.interval.hourly': 'Toutes les heures',
'backup.interval.daily': 'Quotidien',
'backup.interval.weekly': 'Hebdomadaire',
@@ -1148,15 +1311,15 @@ const fr: Record = {
'memories.addPhotos': 'Ajouter des photos',
'memories.selectPhotos': 'Sélectionner des photos depuis Immich',
'memories.selectHint': 'Appuyez sur les photos pour les sélectionner.',
- 'memories.selected': 'sélectionnées',
+ 'memories.selected': 'sélectionné(s)',
'memories.addSelected': 'Ajouter {count} photos',
- 'memories.alreadyAdded': 'Déjà ajoutée',
+ 'memories.alreadyAdded': 'Ajouté',
'memories.private': 'Privé',
'memories.stopSharing': 'Arrêter le partage',
'memories.tripDates': 'Dates du voyage',
'memories.allPhotos': 'Toutes les photos',
'memories.confirmShareTitle': 'Partager avec les membres du voyage ?',
- 'memories.confirmShareHint': '{count} photos seront visibles par tous les membres de ce voyage. Vous pourrez rendre des photos privées ultérieurement.',
+ 'memories.confirmShareHint': '{count} photos seront visibles par tous les membres de ce voyage. Vous pourrez rendre des photos individuelles privées plus tard.',
'memories.confirmShareButton': 'Partager les photos',
// Collab Addon
diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts
new file mode 100644
index 0000000..ca68de1
--- /dev/null
+++ b/client/src/i18n/translations/hu.ts
@@ -0,0 +1,1394 @@
+const hu: Record = {
+ // Általános
+ 'common.save': 'Mentés',
+ 'common.cancel': 'Mégse',
+ 'common.delete': 'Törlés',
+ 'common.edit': 'Szerkesztés',
+ 'common.add': 'Hozzáadás',
+ 'common.loading': 'Betöltés...',
+ 'common.error': 'Hiba',
+ 'common.back': 'Vissza',
+ 'common.all': 'Összes',
+ 'common.close': 'Bezárás',
+ 'common.open': 'Megnyitás',
+ 'common.upload': 'Feltöltés',
+ 'common.search': 'Keresés',
+ 'common.confirm': 'Megerősítés',
+ 'common.ok': 'OK',
+ 'common.yes': 'Igen',
+ 'common.no': 'Nem',
+ 'common.or': 'vagy',
+ 'common.none': 'Nincs',
+ 'common.date': 'Dátum',
+ 'common.rename': 'Átnevezés',
+ 'common.name': 'Név',
+ 'common.email': 'E-mail',
+ 'common.password': 'Jelszó',
+ 'common.saving': 'Mentés...',
+ 'common.update': 'Frissítés',
+ 'common.change': 'Módosítás',
+ 'common.uploading': 'Feltöltés…',
+ 'common.backToPlanning': 'Vissza a tervezéshez',
+ 'common.reset': 'Visszaállítás',
+
+ // Navbar
+ 'nav.trip': 'Utazás',
+ 'nav.share': 'Megosztás',
+ 'nav.settings': 'Beállítások',
+ 'nav.admin': 'Admin',
+ 'nav.logout': 'Kijelentkezés',
+ 'nav.lightMode': 'Világos mód',
+ 'nav.darkMode': 'Sötét mód',
+ 'nav.autoMode': 'Automatikus mód',
+ 'nav.administrator': 'Adminisztrátor',
+
+ // Irányítópult
+ 'dashboard.title': 'Utazásaim',
+ 'dashboard.subtitle.loading': 'Utazások betöltése...',
+ 'dashboard.subtitle.trips': '{count} utazás ({archived} archivált)',
+ 'dashboard.subtitle.empty': 'Indítsd el az első utazásodat',
+ 'dashboard.subtitle.activeOne': '{count} aktív utazás',
+ 'dashboard.subtitle.activeMany': '{count} aktív utazás',
+ 'dashboard.subtitle.archivedSuffix': ' · {count} archivált',
+ 'dashboard.newTrip': 'Új utazás',
+ 'dashboard.gridView': 'Rácsnézet',
+ 'dashboard.listView': 'Listanézet',
+ 'dashboard.currency': 'Pénznem',
+ 'dashboard.timezone': 'Időzónák',
+ 'dashboard.localTime': 'Helyi',
+ 'dashboard.timezoneCustomTitle': 'Egyéni időzóna',
+ 'dashboard.timezoneCustomLabelPlaceholder': 'Címke (opcionális)',
+ 'dashboard.timezoneCustomTzPlaceholder': 'pl. America/New_York',
+ 'dashboard.timezoneCustomAdd': 'Hozzáadás',
+ 'dashboard.timezoneCustomErrorEmpty': 'Adj meg egy időzóna-azonosítót',
+ 'dashboard.timezoneCustomErrorInvalid': 'Érvénytelen időzóna. Használj Europe/Berlin formátumot',
+ 'dashboard.timezoneCustomErrorDuplicate': 'Már hozzáadva',
+ 'dashboard.emptyTitle': 'Még nincsenek utazások',
+ 'dashboard.emptyText': 'Hozd létre az első utazásodat, és kezdj el tervezni helyeket, napi programokat és csomagolási listákat.',
+ 'dashboard.emptyButton': 'Első utazás létrehozása',
+ 'dashboard.nextTrip': 'Következő utazás',
+ 'dashboard.shared': 'Megosztott',
+ 'dashboard.sharedBy': 'Megosztotta: {name}',
+ 'dashboard.days': 'nap',
+ 'dashboard.places': 'hely',
+ 'dashboard.archive': 'Archiválás',
+ 'dashboard.restore': 'Visszaállítás',
+ 'dashboard.archived': 'Archivált',
+ 'dashboard.status.ongoing': 'Folyamatban',
+ 'dashboard.status.today': 'Ma',
+ 'dashboard.status.tomorrow': 'Holnap',
+ 'dashboard.status.past': 'Múlt',
+ 'dashboard.status.daysLeft': 'Még {count} nap',
+ 'dashboard.toast.loadError': 'Nem sikerült betölteni az utazásokat',
+ 'dashboard.toast.created': 'Utazás sikeresen létrehozva!',
+ 'dashboard.toast.createError': 'Nem sikerült létrehozni',
+ 'dashboard.toast.updated': 'Utazás frissítve!',
+ 'dashboard.toast.updateError': 'Nem sikerült frissíteni',
+ 'dashboard.toast.deleted': 'Utazás törölve',
+ 'dashboard.toast.deleteError': 'Nem sikerült törölni',
+ 'dashboard.toast.archived': 'Utazás archiválva',
+ 'dashboard.toast.archiveError': 'Nem sikerült archiválni',
+ 'dashboard.toast.restored': 'Utazás visszaállítva',
+ 'dashboard.toast.restoreError': 'Nem sikerült visszaállítani',
+ 'dashboard.confirm.delete': '"{title}" utazás törlése? Minden hely és terv véglegesen törlődik.',
+ 'dashboard.editTrip': 'Utazás szerkesztése',
+ 'dashboard.createTrip': 'Új utazás létrehozása',
+ 'dashboard.tripTitle': 'Cím',
+ 'dashboard.tripTitlePlaceholder': 'pl. Nyár Japánban',
+ 'dashboard.tripDescription': 'Leírás',
+ 'dashboard.tripDescriptionPlaceholder': 'Miről szól ez az utazás?',
+ 'dashboard.startDate': 'Kezdő dátum',
+ 'dashboard.endDate': 'Záró dátum',
+ 'dashboard.noDateHint': 'Nincs dátum megadva — 7 alapértelmezett nap jön létre. Ezt bármikor módosíthatod.',
+ 'dashboard.coverImage': 'Borítókép',
+ 'dashboard.addCoverImage': 'Borítókép hozzáadása',
+ 'dashboard.addMembers': 'Útitársak',
+ 'dashboard.addMember': 'Tag hozzáadása',
+ 'dashboard.coverSaved': 'Borítókép mentve',
+ 'dashboard.coverUploadError': 'Feltöltés sikertelen',
+ 'dashboard.coverRemoveError': 'Eltávolítás sikertelen',
+ 'dashboard.titleRequired': 'A cím megadása kötelező',
+ 'dashboard.endDateError': 'A záró dátumnak a kezdő dátum után kell lennie',
+
+ // Beállítások
+ 'settings.title': 'Beállítások',
+ 'settings.subtitle': 'Személyes beállítások konfigurálása',
+ 'settings.map': 'Térkép',
+ 'settings.mapTemplate': 'Térkép sablon',
+ 'settings.mapTemplatePlaceholder.select': 'Sablon kiválasztása...',
+ 'settings.mapDefaultHint': 'Hagyd üresen az OpenStreetMap használatához (alapértelmezett)',
+ 'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
+ 'settings.mapHint': 'URL sablon a térképcsempékhez',
+ 'settings.latitude': 'Szélességi fok',
+ 'settings.longitude': 'Hosszúsági fok',
+ 'settings.saveMap': 'Térkép mentése',
+ 'settings.apiKeys': 'API kulcsok',
+ 'settings.mapsKey': 'Google Maps API kulcs',
+ 'settings.mapsKeyHint': 'Helykereséséhez. Places API (New) szükséges. Létrehozás: console.cloud.google.com',
+ 'settings.weatherKey': 'OpenWeatherMap API kulcs',
+ 'settings.weatherKeyHint': 'Időjárás adatokhoz. Ingyenes: openweathermap.org/api',
+ 'settings.keyPlaceholder': 'Kulcs megadása...',
+ 'settings.configured': 'Konfigurálva',
+ 'settings.saveKeys': 'Kulcsok mentése',
+ 'settings.display': 'Megjelenítés',
+ 'settings.colorMode': 'Színmód',
+ 'settings.light': 'Világos',
+ 'settings.dark': 'Sötét',
+ 'settings.auto': 'Automatikus',
+ 'settings.language': 'Nyelv',
+ 'settings.temperature': 'Hőmérséklet egység',
+ 'settings.timeFormat': 'Időformátum',
+ 'settings.routeCalculation': 'Útvonalszámítás',
+ 'settings.blurBookingCodes': 'Foglalási kódok elrejtése',
+ 'settings.notifications': 'Értesítések',
+ 'settings.notifyTripInvite': 'Utazási meghívók',
+ 'settings.notifyBookingChange': 'Foglalási változások',
+ 'settings.notifyTripReminder': 'Utazási emlékeztetők',
+ 'settings.notifyVacayInvite': 'Vacay összevonási meghívók',
+ 'settings.notifyPhotosShared': 'Megosztott fotók (Immich)',
+ 'settings.notifyCollabMessage': 'Csevegés üzenetek (Collab)',
+ 'settings.notifyPackingTagged': 'Csomagolási lista: hozzárendelések',
+ 'settings.notifyWebhook': 'Webhook értesítések',
+ 'settings.on': 'Be',
+ 'settings.off': 'Ki',
+ 'settings.mcp.title': 'MCP konfiguráció',
+ 'settings.mcp.endpoint': 'MCP végpont',
+ 'settings.mcp.clientConfig': 'Kliens konfiguráció',
+ 'settings.mcp.clientConfigHint': 'Cserélje ki a részt egy API tokenre az alábbi listából. Az npx elérési útját szükség lehet módosítani a rendszeréhez (pl. C:\\PROGRA~1\\nodejs\\npx.cmd Windows-on).',
+ 'settings.mcp.copy': 'Másolás',
+ 'settings.mcp.copied': 'Másolva!',
+ 'settings.mcp.apiTokens': 'API tokenek',
+ 'settings.mcp.createToken': 'Új token létrehozása',
+ 'settings.mcp.noTokens': 'Még nincsenek tokenek. Hozzon létre egyet MCP kliensek csatlakoztatásához.',
+ 'settings.mcp.tokenCreatedAt': 'Létrehozva',
+ 'settings.mcp.tokenUsedAt': 'Használva',
+ 'settings.mcp.deleteTokenTitle': 'Token törlése',
+ 'settings.mcp.deleteTokenMessage': 'Ez a token azonnal érvénytelenné válik. Minden MCP kliens, amely használja, elveszíti a hozzáférést.',
+ 'settings.mcp.modal.createTitle': 'API token létrehozása',
+ 'settings.mcp.modal.tokenName': 'Token neve',
+ 'settings.mcp.modal.tokenNamePlaceholder': 'pl. Claude Desktop, Munkahelyi laptop',
+ 'settings.mcp.modal.creating': 'Létrehozás…',
+ 'settings.mcp.modal.create': 'Token létrehozása',
+ 'settings.mcp.modal.createdTitle': 'Token létrehozva',
+ 'settings.mcp.modal.createdWarning': 'Ez a token csak egyszer jelenik meg. Másolja és mentse el most — nem lehet visszaállítani.',
+ 'settings.mcp.modal.done': 'Kész',
+ 'settings.mcp.toast.created': 'Token létrehozva',
+ 'settings.mcp.toast.createError': 'Nem sikerült létrehozni a tokent',
+ 'settings.mcp.toast.deleted': 'Token törölve',
+ 'settings.mcp.toast.deleteError': 'Nem sikerült törölni a tokent',
+ 'settings.account': 'Fiók',
+ 'settings.username': 'Felhasználónév',
+ 'settings.email': 'E-mail',
+ 'settings.role': 'Szerepkör',
+ 'settings.roleAdmin': 'Adminisztrátor',
+ 'settings.oidcLinked': 'Összekapcsolva:',
+ 'settings.changePassword': 'Jelszó módosítása',
+ 'settings.currentPassword': 'Jelenlegi jelszó',
+ 'settings.newPassword': 'Új jelszó',
+ 'settings.confirmPassword': 'Új jelszó megerősítése',
+ 'settings.updatePassword': 'Jelszó frissítése',
+ 'settings.passwordRequired': 'Kérjük, add meg a jelenlegi és az új jelszót',
+ 'settings.currentPasswordRequired': 'A jelenlegi jelszó megadása kötelező',
+ 'settings.passwordTooShort': 'A jelszónak legalább 8 karakter hosszúnak kell lennie',
+ 'settings.passwordWeak': 'A jelszónak tartalmaznia kell nagybetűt, kisbetűt és számot',
+ 'settings.passwordMismatch': 'A jelszavak nem egyeznek',
+ 'settings.passwordChanged': 'Jelszó sikeresen módosítva',
+ 'settings.deleteAccount': 'Törlés',
+ 'settings.deleteAccountTitle': 'Biztosan törölni szeretnéd a fiókodat?',
+ 'settings.deleteAccountWarning': 'A fiókod és minden utazásod, helyed és fájlod véglegesen törlődik. Ez a művelet nem vonható vissza.',
+ 'settings.deleteAccountConfirm': 'Végleges törlés',
+ 'settings.deleteBlockedTitle': 'Törlés nem lehetséges',
+ 'settings.deleteBlockedMessage': 'Te vagy az egyetlen adminisztrátor. Nevezz ki egy másik felhasználót adminnak, mielőtt törölnéd a fiókodat.',
+ 'settings.roleUser': 'Felhasználó',
+ 'settings.saveProfile': 'Mentés',
+ 'settings.toast.mapSaved': 'Térképbeállítások mentve',
+ 'settings.toast.keysSaved': 'API kulcsok mentve',
+ 'settings.toast.displaySaved': 'Megjelenítési beállítások mentve',
+ 'settings.toast.profileSaved': 'Profil frissítve',
+ 'settings.uploadAvatar': 'Profilkép feltöltése',
+ 'settings.removeAvatar': 'Profilkép eltávolítása',
+ 'settings.avatarUploaded': 'Profilkép frissítve',
+ 'settings.avatarRemoved': 'Profilkép eltávolítva',
+ 'settings.avatarError': 'Feltöltés sikertelen',
+ 'settings.mfa.title': 'Kétfaktoros hitelesítés (2FA)',
+ 'settings.mfa.description': 'Egy második lépést ad a bejelentkezéshez e-mail és jelszó használatakor. Használj hitelesítő alkalmazást (Google Authenticator, Authy stb.).',
+ 'settings.mfa.requiredByPolicy': 'A rendszergazda kétlépcsős hitelesítést ír elő. Állíts be hitelesítő alkalmazást lent, mielőtt továbblépnél.',
+ 'settings.mfa.backupTitle': 'Tartalék kódok',
+ 'settings.mfa.backupDescription': 'Használd ezeket az egyszer használatos kódokat, ha elveszíted a hozzáférést a hitelesítő alkalmazásodhoz.',
+ 'settings.mfa.backupWarning': 'Mentsd el ezeket most. Minden kód csak egyszer használható.',
+ 'settings.mfa.backupCopy': 'Kódok másolása',
+ 'settings.mfa.backupDownload': 'TXT letöltése',
+ 'settings.mfa.backupPrint': 'Nyomtatás / PDF',
+ 'settings.mfa.backupCopied': 'Tartalék kódok másolva',
+ 'settings.mfa.enabled': '2FA engedélyezve van a fiókodban.',
+ 'settings.mfa.disabled': '2FA nincs engedélyezve.',
+ 'settings.mfa.setup': 'Hitelesítő beállítása',
+ 'settings.mfa.scanQr': 'Olvasd be ezt a QR-kódot az alkalmazásoddal, vagy add meg manuálisan a titkos kulcsot.',
+ 'settings.mfa.secretLabel': 'Titkos kulcs (kézi megadás)',
+ 'settings.mfa.codePlaceholder': '6 jegyű kód',
+ 'settings.mfa.enable': '2FA engedélyezése',
+ 'settings.mfa.cancelSetup': 'Mégse',
+ 'settings.mfa.disableTitle': '2FA kikapcsolása',
+ 'settings.mfa.disableHint': 'Add meg a fiókod jelszavát és a hitelesítő alkalmazás aktuális kódját.',
+ 'settings.mfa.disable': '2FA kikapcsolása',
+ 'settings.mfa.toastEnabled': 'Kétfaktoros hitelesítés engedélyezve',
+ 'settings.mfa.toastDisabled': 'Kétfaktoros hitelesítés kikapcsolva',
+ 'settings.mfa.demoBlocked': 'Demo módban nem érhető el',
+ 'admin.smtp.title': 'E-mail és értesítések',
+ 'admin.smtp.hint': 'SMTP konfiguráció e-mail értesítésekhez. Opcionális: Webhook URL Discordhoz, Slackhez stb.',
+ 'admin.smtp.testButton': 'Teszt e-mail küldése',
+ 'admin.smtp.testSuccess': 'Teszt e-mail sikeresen elküldve',
+ 'admin.smtp.testFailed': 'Teszt e-mail küldése sikertelen',
+ 'dayplan.icsTooltip': 'Naptár exportálása (ICS)',
+ 'share.linkTitle': 'Nyilvános link',
+ 'share.linkHint': 'Hozz létre egy linket, amellyel bárki megtekintheti ezt az utazást bejelentkezés nélkül. Csak olvasható — szerkesztés nem lehetséges.',
+ 'share.createLink': 'Link létrehozása',
+ 'share.deleteLink': 'Link törlése',
+ 'share.createError': 'Nem sikerült létrehozni a linket',
+ 'common.copy': 'Másolás',
+ 'common.copied': 'Másolva',
+ 'share.permMap': 'Térkép és terv',
+ 'share.permBookings': 'Foglalások',
+ 'share.permPacking': 'Csomagolás',
+ 'shared.expired': 'Link lejárt vagy érvénytelen',
+ 'shared.expiredHint': 'Ez a megosztott utazási link már nem aktív.',
+ 'shared.readOnly': 'Csak olvasható megosztott nézet',
+ 'shared.tabPlan': 'Terv',
+ 'shared.tabBookings': 'Foglalások',
+ 'shared.tabPacking': 'Csomagolás',
+ 'shared.tabBudget': 'Költségvetés',
+ 'shared.tabChat': 'Csevegés',
+ 'shared.days': 'nap',
+ 'shared.places': 'hely',
+ 'shared.other': 'Egyéb',
+ 'shared.totalBudget': 'Teljes költségvetés',
+ 'shared.messages': 'üzenet',
+ 'shared.sharedVia': 'Megosztva:',
+ 'shared.confirmed': 'Megerősítve',
+ 'shared.pending': 'Függőben',
+ 'share.permBudget': 'Költségvetés',
+ 'share.permCollab': 'Csevegés',
+
+ // Bejelentkezés
+ 'login.error': 'Bejelentkezés sikertelen. Kérjük, ellenőrizd a megadott adatokat.',
+ 'login.tagline': 'Az utazásaid.\nA terved.',
+ 'login.description': 'Tervezz utazásokat közösen interaktív térképekkel, költségvetéssel és valós idejű szinkronizálással.',
+ 'login.features.maps': 'Interaktív térképek',
+ 'login.features.mapsDesc': 'Google Places, útvonalak és csoportosítás',
+ 'login.features.realtime': 'Valós idejű szinkron',
+ 'login.features.realtimeDesc': 'Közös tervezés WebSocket-en keresztül',
+ 'login.features.budget': 'Költségvetés-követés',
+ 'login.features.budgetDesc': 'Kategóriák, diagramok és személyenkénti költségek',
+ 'login.features.collab': 'Együttműködés',
+ 'login.features.collabDesc': 'Többfelhasználós, megosztott utazásokkal',
+ 'login.features.packing': 'Csomagolási listák',
+ 'login.features.packingDesc': 'Kategóriák és haladás',
+ 'login.features.bookings': 'Foglalások',
+ 'login.features.bookingsDesc': 'Repülők, szállodák, éttermek és még több',
+ 'login.features.files': 'Dokumentumok',
+ 'login.features.filesDesc': 'Fájlok feltöltése és kezelése',
+ 'login.features.routes': 'Útvonal-optimalizálás',
+ 'login.features.routesDesc': 'Automatikus optimalizálás és Google Maps export',
+ 'login.selfHosted': 'Saját üzemeltetés \u00B7 Nyílt forráskód \u00B7 Az adataid nálad maradnak',
+ 'login.title': 'Bejelentkezés',
+ 'login.subtitle': 'Üdv újra',
+ 'login.signingIn': 'Bejelentkezés…',
+ 'login.signIn': 'Bejelentkezés',
+ 'login.createAdmin': 'Admin fiók létrehozása',
+ 'login.createAdminHint': 'Hozd létre az első admin fiókot a TREK-hez.',
+ 'login.createAccount': 'Fiók létrehozása',
+ 'login.createAccountHint': 'Új fiók regisztrálása.',
+ 'login.creating': 'Létrehozás…',
+ 'login.noAccount': 'Nincs még fiókod?',
+ 'login.hasAccount': 'Már van fiókod?',
+ 'login.register': 'Regisztráció',
+ 'login.emailPlaceholder': 'email@cimed.hu',
+ 'login.username': 'Felhasználónév',
+ 'login.oidc.registrationDisabled': 'A regisztráció le van tiltva. Lépj kapcsolatba az adminisztrátorral.',
+ 'login.oidc.noEmail': 'Nem érkezett e-mail a szolgáltatótól.',
+ 'login.oidc.tokenFailed': 'Hitelesítés sikertelen.',
+ 'login.oidc.invalidState': 'Érvénytelen munkamenet. Kérjük, próbáld újra.',
+ 'login.demoFailed': 'Demo bejelentkezés sikertelen',
+ 'login.oidcSignIn': 'Bejelentkezés ezzel: {name}',
+ 'login.oidcOnly': 'A jelszavas hitelesítés le van tiltva. Kérjük, jelentkezz be az SSO szolgáltatódon keresztül.',
+ 'login.demoHint': 'Próbáld ki a demót — regisztráció nélkül',
+ 'login.mfaTitle': 'Kétfaktoros hitelesítés',
+ 'login.mfaSubtitle': 'Add meg a 6 jegyű kódot a hitelesítő alkalmazásból.',
+ 'login.mfaCodeLabel': 'Ellenőrző kód',
+ 'login.mfaCodeRequired': 'Add meg a kódot a hitelesítő alkalmazásból.',
+ 'login.mfaHint': 'Nyisd meg a Google Authenticator, Authy vagy más TOTP alkalmazást.',
+ 'login.mfaBack': '← Vissza a bejelentkezéshez',
+ 'login.mfaVerify': 'Ellenőrzés',
+
+ // Regisztráció
+ 'register.passwordMismatch': 'A jelszavak nem egyeznek',
+ 'register.passwordTooShort': 'A jelszónak legalább 6 karakter hosszúnak kell lennie',
+ 'register.failed': 'Regisztráció sikertelen',
+ 'register.getStarted': 'Kezdjük',
+ 'register.subtitle': 'Hozz létre egy fiókot, és kezdd el megtervezni álomutazásaidat.',
+ 'register.feature1': 'Korlátlan utazási tervek',
+ 'register.feature2': 'Interaktív térképnézet',
+ 'register.feature3': 'Helyek és kategóriák kezelése',
+ 'register.feature4': 'Foglalások nyomon követése',
+ 'register.feature5': 'Csomagolási listák készítése',
+ 'register.feature6': 'Fényképek és fájlok tárolása',
+ 'register.createAccount': 'Fiók létrehozása',
+ 'register.startPlanning': 'Kezdd el az utazástervezést',
+ 'register.minChars': 'Min. 6 karakter',
+ 'register.confirmPassword': 'Jelszó megerősítése',
+ 'register.repeatPassword': 'Jelszó ismétlése',
+ 'register.registering': 'Regisztráció...',
+ 'register.register': 'Regisztráció',
+ 'register.hasAccount': 'Már van fiókod?',
+ 'register.signIn': 'Bejelentkezés',
+
+ // Admin
+ 'admin.title': 'Adminisztráció',
+ 'admin.subtitle': 'Felhasználókezelés és rendszerbeállítások',
+ 'admin.tabs.users': 'Felhasználók',
+ 'admin.tabs.categories': 'Kategóriák',
+ 'admin.tabs.backup': 'Biztonsági mentés',
+ 'admin.stats.users': 'Felhasználók',
+ 'admin.stats.trips': 'Utazások',
+ 'admin.stats.places': 'Helyek',
+ 'admin.stats.photos': 'Fotók',
+ 'admin.stats.files': 'Fájlok',
+ 'admin.table.user': 'Felhasználó',
+ 'admin.table.email': 'E-mail',
+ 'admin.table.role': 'Szerepkör',
+ 'admin.table.created': 'Létrehozva',
+ 'admin.table.lastLogin': 'Utolsó belépés',
+ 'admin.table.actions': 'Műveletek',
+ 'admin.you': '(Te)',
+ 'admin.editUser': 'Felhasználó szerkesztése',
+ 'admin.newPassword': 'Új jelszó',
+ 'admin.newPasswordHint': 'Hagyd üresen a jelenlegi jelszó megtartásához',
+ 'admin.deleteUser': '"{name}" felhasználó törlése? Minden utazás véglegesen törlődik.',
+ 'admin.deleteUserTitle': 'Felhasználó törlése',
+ 'admin.newPasswordPlaceholder': 'Új jelszó megadása…',
+ 'admin.toast.loadError': 'Nem sikerült betölteni az admin adatokat',
+ 'admin.toast.userUpdated': 'Felhasználó frissítve',
+ 'admin.toast.updateError': 'Nem sikerült frissíteni',
+ 'admin.toast.userDeleted': 'Felhasználó törölve',
+ 'admin.toast.deleteError': 'Nem sikerült törölni',
+ 'admin.toast.cannotDeleteSelf': 'Saját fiók nem törölhető',
+ 'admin.toast.userCreated': 'Felhasználó létrehozva',
+ 'admin.toast.createError': 'Nem sikerült létrehozni a felhasználót',
+ 'admin.toast.fieldsRequired': 'Felhasználónév, e-mail és jelszó megadása kötelező',
+ 'admin.createUser': 'Felhasználó létrehozása',
+ 'admin.invite.title': 'Meghívó linkek',
+ 'admin.invite.subtitle': 'Egyszer használatos regisztrációs linkek létrehozása',
+ 'admin.invite.create': 'Link létrehozása',
+ 'admin.invite.createAndCopy': 'Létrehozás és másolás',
+ 'admin.invite.empty': 'Még nincsenek meghívó linkek',
+ 'admin.invite.maxUses': 'Max. használat',
+ 'admin.invite.expiry': 'Lejárat',
+ 'admin.invite.uses': 'felhasználva',
+ 'admin.invite.expiresAt': 'lejár',
+ 'admin.invite.createdBy': 'készítette',
+ 'admin.invite.active': 'Aktív',
+ 'admin.invite.expired': 'Lejárt',
+ 'admin.invite.usedUp': 'Elhasználva',
+ 'admin.invite.copied': 'Meghívó link vágólapra másolva',
+ 'admin.invite.copyLink': 'Link másolása',
+ 'admin.invite.deleted': 'Meghívó link törölve',
+ 'admin.invite.createError': 'Nem sikerült létrehozni a meghívó linket',
+ 'admin.invite.deleteError': 'Nem sikerült törölni a meghívó linket',
+ 'admin.tabs.settings': 'Beállítások',
+ 'admin.allowRegistration': 'Regisztráció engedélyezése',
+ 'admin.allowRegistrationHint': 'Új felhasználók regisztrálhatják magukat',
+ 'admin.requireMfa': 'Kétlépcsős hitelesítés (2FA) kötelezővé tétele',
+ 'admin.requireMfaHint': 'A 2FA nélküli felhasználóknak a Beállításokban kell befejezniük a beállítást az alkalmazás használata előtt.',
+ 'admin.apiKeys': 'API kulcsok',
+ 'admin.apiKeysHint': 'Opcionális. Bővített helyadatokat tesz lehetővé, például fotókat és időjárást.',
+ 'admin.mapsKey': 'Google Maps API kulcs',
+ 'admin.mapsKeyHint': 'Helykereséshez szükséges. Létrehozás: console.cloud.google.com',
+ 'admin.mapsKeyHintLong': 'API kulcs nélkül az OpenStreetMap szolgál helykeresésre. Google API kulccsal képek, értékelések és nyitvatartás is betölthetők. Létrehozás: console.cloud.google.com.',
+ 'admin.recommended': 'Ajánlott',
+ 'admin.weatherKey': 'OpenWeatherMap API kulcs',
+ 'admin.weatherKeyHint': 'Időjárás adatokhoz. Ingyenes: openweathermap.org',
+ 'admin.validateKey': 'Teszt',
+ 'admin.keyValid': 'Csatlakozva',
+ 'admin.keyInvalid': 'Érvénytelen',
+ 'admin.keySaved': 'API kulcsok mentve',
+ 'admin.oidcTitle': 'Egyszeri bejelentkezés (OIDC)',
+ 'admin.oidcSubtitle': 'Bejelentkezés külső szolgáltatókon keresztül, pl. Google, Apple, Authentik vagy Keycloak.',
+ 'admin.oidcDisplayName': 'Megjelenítendő név',
+ 'admin.oidcIssuer': 'Issuer URL',
+ 'admin.oidcIssuerHint': 'A szolgáltató OpenID Connect Issuer URL-je. pl. https://accounts.google.com',
+ 'admin.oidcSaved': 'OIDC konfiguráció mentve',
+ 'admin.oidcOnlyMode': 'Jelszavas hitelesítés letiltása',
+ 'admin.oidcOnlyModeHint': 'Ha engedélyezve van, csak SSO bejelentkezés lehetséges. A jelszavas bejelentkezés és regisztráció le van tiltva.',
+
+ // Fájltípusok
+ 'admin.fileTypes': 'Engedélyezett fájltípusok',
+ 'admin.fileTypesHint': 'Állítsd be, milyen fájltípusokat tölthetnek fel a felhasználók.',
+ 'admin.fileTypesFormat': 'Vesszővel elválasztott kiterjesztések (pl. jpg,png,pdf,doc). Használj *-ot az összes típus engedélyezéséhez.',
+ 'admin.fileTypesSaved': 'Fájltípus-beállítások mentve',
+
+ // Csomagolási sablonok és poggyászkövetés
+ 'admin.bagTracking.title': 'Poggyászkövetés',
+ 'admin.bagTracking.subtitle': 'Súly- és táskahozzárendelés engedélyezése csomagolási tételeknél',
+ 'admin.tabs.config': 'Konfiguráció',
+ 'admin.tabs.templates': 'Csomagolási sablonok',
+ 'admin.packingTemplates.title': 'Csomagolási sablonok',
+ 'admin.packingTemplates.subtitle': 'Újrafelhasználható csomagolási listák létrehozása utazásaidhoz',
+ 'admin.packingTemplates.create': 'Új sablon',
+ 'admin.packingTemplates.namePlaceholder': 'Sablon neve (pl. Tengerparti nyaralás)',
+ 'admin.packingTemplates.empty': 'Még nincsenek sablonok',
+ 'admin.packingTemplates.items': 'tétel',
+ 'admin.packingTemplates.categories': 'kategória',
+ 'admin.packingTemplates.itemName': 'Tétel neve',
+ 'admin.packingTemplates.itemCategory': 'Kategória',
+ 'admin.packingTemplates.categoryName': 'Kategória neve (pl. Ruházat)',
+ 'admin.packingTemplates.addCategory': 'Kategória hozzáadása',
+ 'admin.packingTemplates.created': 'Sablon létrehozva',
+ 'admin.packingTemplates.deleted': 'Sablon törölve',
+ 'admin.packingTemplates.loadError': 'Nem sikerült betölteni a sablonokat',
+ 'admin.packingTemplates.createError': 'Nem sikerült létrehozni a sablont',
+ 'admin.packingTemplates.deleteError': 'Nem sikerült törölni a sablont',
+ 'admin.packingTemplates.saveError': 'Nem sikerült menteni',
+
+ // Bővítmények
+ 'admin.tabs.addons': 'Bővítmények',
+ 'admin.addons.title': 'Bővítmények',
+ 'admin.addons.subtitle': 'Funkciók engedélyezése vagy letiltása a TREK testreszabásához.',
+ 'admin.addons.catalog.packing.name': 'Csomagolás',
+ 'admin.addons.catalog.packing.description': 'Ellenőrzőlisták a poggyász előkészítéséhez minden utazáshoz',
+ 'admin.addons.catalog.budget.name': 'Költségvetés',
+ 'admin.addons.catalog.budget.description': 'Kiadások nyomon követése és az utazási költségvetés tervezése',
+ 'admin.addons.catalog.documents.name': 'Dokumentumok',
+ 'admin.addons.catalog.documents.description': 'Úti dokumentumok tárolása és kezelése',
+ 'admin.addons.catalog.vacay.name': 'Vacay',
+ 'admin.addons.catalog.vacay.description': 'Személyes szabadságtervező naptárnézettel',
+ 'admin.addons.catalog.atlas.name': 'Atlasz',
+ 'admin.addons.catalog.atlas.description': 'Világtérkép meglátogatott országokkal és utazási statisztikákkal',
+ 'admin.addons.catalog.collab.name': 'Együttműködés',
+ 'admin.addons.catalog.collab.description': 'Valós idejű jegyzetek, szavazások és csevegés az utazás tervezéséhez',
+ 'admin.addons.catalog.memories.name': 'Fotók (Immich)',
+ 'admin.addons.catalog.memories.description': 'Utazási fotók megosztása az Immich példányon keresztül',
+ 'admin.addons.catalog.mcp.name': 'MCP',
+ 'admin.addons.catalog.mcp.description': 'Model Context Protocol AI asszisztens integrációhoz',
+ 'admin.addons.subtitleBefore': 'Funkciók engedélyezése vagy letiltása a ',
+ 'admin.addons.subtitleAfter': ' testreszabásához.',
+ 'admin.addons.enabled': 'Engedélyezve',
+ 'admin.addons.disabled': 'Letiltva',
+ 'admin.addons.type.trip': 'Utazás',
+ 'admin.addons.type.global': 'Globális',
+ 'admin.addons.type.integration': 'Integráció',
+ 'admin.addons.tripHint': 'Fülként érhető el minden utazáson belül',
+ 'admin.addons.globalHint': 'Önálló szekcióként elérhető a fő navigációban',
+ 'admin.addons.integrationHint': 'Háttérszolgáltatások és API integrációk dedikált oldal nélkül',
+ 'admin.addons.toast.updated': 'Bővítmény frissítve',
+ 'admin.addons.toast.error': 'Nem sikerült frissíteni a bővítményt',
+ 'admin.addons.noAddons': 'Nincsenek elérhető bővítmények',
+ // Időjárás információ
+ 'admin.weather.title': 'Időjárás adatok',
+ 'admin.weather.badge': '2026. március 24. óta',
+ 'admin.weather.description': 'A TREK az Open-Meteo-t használja időjárás-adatforrásként. Az Open-Meteo egy ingyenes, nyílt forráskódú időjárás-szolgáltatás — nincs szükség API kulcsra.',
+ 'admin.weather.forecast': '16 napos előrejelzés',
+ 'admin.weather.forecastDesc': 'Korábban 5 nap volt (OpenWeatherMap)',
+ 'admin.weather.climate': 'Történelmi klímaadatok',
+ 'admin.weather.climateDesc': 'Az elmúlt 85 év átlagai a 16 napos előrejelzésen túli napokhoz',
+ 'admin.weather.requests': '10 000 kérés / nap',
+ 'admin.weather.requestsDesc': 'Ingyenes, nincs szükség API kulcsra',
+ 'admin.weather.locationHint': 'Az időjárás az adott nap első koordinátákkal rendelkező helye alapján készül. Ha nincs hely hozzárendelve a naphoz, a helylista bármelyik helye szolgál referenciául.',
+
+ 'admin.tabs.audit': 'Auditnapló',
+
+ 'admin.audit.subtitle': 'Biztonsági és adminisztrációs események (mentések, felhasználók, 2FA, beállítások).',
+ 'admin.audit.empty': 'Még nincsenek audit bejegyzések.',
+ 'admin.audit.refresh': 'Frissítés',
+ 'admin.audit.loadMore': 'Továbbiak betöltése',
+ 'admin.audit.showing': '{count} betöltve · {total} összesen',
+ 'admin.audit.col.time': 'Időpont',
+ 'admin.audit.col.user': 'Felhasználó',
+ 'admin.audit.col.action': 'Művelet',
+ 'admin.audit.col.resource': 'Erőforrás',
+ 'admin.audit.col.ip': 'IP',
+ 'admin.audit.col.details': 'Részletek',
+
+ // MCP Tokens
+ 'admin.tabs.mcpTokens': 'MCP tokenek',
+ 'admin.mcpTokens.title': 'MCP tokenek',
+ 'admin.mcpTokens.subtitle': 'Összes felhasználó API tokeneinek kezelése',
+ 'admin.mcpTokens.owner': 'Tulajdonos',
+ 'admin.mcpTokens.tokenName': 'Token neve',
+ 'admin.mcpTokens.created': 'Létrehozva',
+ 'admin.mcpTokens.lastUsed': 'Utoljára használva',
+ 'admin.mcpTokens.never': 'Soha',
+ 'admin.mcpTokens.empty': 'Még nem hoztak létre MCP tokeneket',
+ 'admin.mcpTokens.deleteTitle': 'Token törlése',
+ 'admin.mcpTokens.deleteMessage': 'Ez a token azonnal érvénytelenítésre kerül. A felhasználó elveszíti az MCP hozzáférést ezen a tokenen keresztül.',
+ 'admin.mcpTokens.deleteSuccess': 'Token törölve',
+ 'admin.mcpTokens.deleteError': 'Nem sikerült törölni a tokent',
+ 'admin.mcpTokens.loadError': 'Nem sikerült betölteni a tokeneket',
+
+ // GitHub
+ 'admin.tabs.github': 'GitHub',
+ 'admin.github.title': 'Frissítési előzmények',
+ 'admin.github.subtitle': 'Legújabb frissítések: {repo}',
+ 'admin.github.latest': 'Legújabb',
+ 'admin.github.prerelease': 'Előzetes kiadás',
+ 'admin.github.showDetails': 'Részletek megjelenítése',
+ 'admin.github.hideDetails': 'Részletek elrejtése',
+ 'admin.github.loadMore': 'Továbbiak betöltése',
+ 'admin.github.loading': 'Betöltés...',
+ 'admin.github.error': 'Nem sikerült betölteni a kiadásokat',
+ 'admin.github.by': 'készítette',
+ 'admin.github.support': 'Segít fenntartani a TREK fejlesztését',
+
+ 'admin.update.available': 'Frissítés elérhető',
+ 'admin.update.text': 'A TREK {version} elérhető. Jelenleg a {current} verziót használod.',
+ 'admin.update.button': 'Megtekintés a GitHubon',
+ 'admin.update.install': 'Frissítés telepítése',
+ 'admin.update.confirmTitle': 'Frissítés telepítése?',
+ 'admin.update.confirmText': 'A TREK frissítésre kerül {current} verzióról {version} verzióra. A szerver ezután automatikusan újraindul.',
+ 'admin.update.dataInfo': 'Minden adat (utazások, felhasználók, API kulcsok, feltöltések, Vacay, Atlas, költségvetések) megmarad.',
+ 'admin.update.warning': 'Az alkalmazás az újraindítás alatt rövid ideig nem lesz elérhető.',
+ 'admin.update.confirm': 'Frissítés most',
+ 'admin.update.installing': 'Frissítés…',
+ 'admin.update.success': 'Frissítés telepítve! A szerver újraindul…',
+ 'admin.update.failed': 'Frissítés sikertelen',
+ 'admin.update.backupHint': 'Javasoljuk, hogy frissítés előtt készíts biztonsági mentést.',
+ 'admin.update.backupLink': 'Biztonsági mentéshez',
+ 'admin.update.howTo': 'Frissítési útmutató',
+ 'admin.update.dockerText': 'A TREK példányod Dockerben fut. A {version} verzióra frissítéshez futtasd a következő parancsokat a szervereden:',
+ 'admin.update.reloadHint': 'Kérjük, töltsd újra az oldalt néhány másodperc múlva.',
+
+ // Vacay bővítmény
+ 'vacay.subtitle': 'Szabadságnapok tervezése és kezelése',
+ 'vacay.settings': 'Beállítások',
+ 'vacay.year': 'Év',
+ 'vacay.addYear': 'Év hozzáadása',
+ 'vacay.removeYear': 'Év eltávolítása',
+ 'vacay.removeYearConfirm': '{year} eltávolítása?',
+ 'vacay.removeYearHint': 'Az adott év összes szabadság-bejegyzése és céges szabadnapja véglegesen törlődik.',
+ 'vacay.remove': 'Eltávolítás',
+ 'vacay.persons': 'Személyek',
+ 'vacay.noPersons': 'Nincsenek személyek hozzáadva',
+ 'vacay.addPerson': 'Személy hozzáadása',
+ 'vacay.editPerson': 'Személy szerkesztése',
+ 'vacay.removePerson': 'Személy eltávolítása',
+ 'vacay.removePersonConfirm': '{name} eltávolítása?',
+ 'vacay.removePersonHint': 'A személy összes szabadság-bejegyzése véglegesen törlődik.',
+ 'vacay.personName': 'Név',
+ 'vacay.personNamePlaceholder': 'Név megadása',
+ 'vacay.color': 'Szín',
+ 'vacay.add': 'Hozzáadás',
+ 'vacay.legend': 'Jelmagyarázat',
+ 'vacay.publicHoliday': 'Ünnepnap',
+ 'vacay.companyHoliday': 'Céges szabadnap',
+ 'vacay.weekend': 'Hétvége',
+ 'vacay.modeVacation': 'Szabadság',
+ 'vacay.modeCompany': 'Céges szabadnap',
+ 'vacay.entitlement': 'Szabadságkeret',
+ 'vacay.entitlementDays': 'nap',
+ 'vacay.used': 'Felhasznált',
+ 'vacay.remaining': 'Maradt',
+ 'vacay.carriedOver': '{year}-ból/ből',
+ 'vacay.blockWeekends': 'Hétvégék zárolása',
+ 'vacay.blockWeekendsHint': 'Szabadság-bejegyzések megakadályozása szombaton és vasárnap',
+ 'vacay.publicHolidays': 'Ünnepnapok',
+ 'vacay.publicHolidaysHint': 'Ünnepnapok megjelölése a naptárban',
+ 'vacay.selectCountry': 'Ország kiválasztása',
+ 'vacay.selectRegion': 'Régió kiválasztása (opcionális)',
+ 'vacay.addCalendar': 'Naptár hozzáadása',
+ 'vacay.calendarLabel': 'Címke (opcionális)',
+ 'vacay.calendarColor': 'Szín',
+ 'vacay.noCalendars': 'Még nincsenek ünnepnap-naptárak hozzáadva',
+ 'vacay.weekendDays': 'Hétvégi napok',
+ 'vacay.mon': 'Hé',
+ 'vacay.tue': 'Ke',
+ 'vacay.wed': 'Sze',
+ 'vacay.thu': 'Csü',
+ 'vacay.fri': 'Pé',
+ 'vacay.sat': 'Szo',
+ 'vacay.sun': 'Va',
+ 'vacay.companyHolidays': 'Céges szabadnapok',
+ 'vacay.companyHolidaysHint': 'Céges szintű szabadnapok megjelölésének engedélyezése',
+ 'vacay.companyHolidaysNoDeduct': 'A céges szabadnapok nem számítanak bele a szabadságkeretbe.',
+ 'vacay.carryOver': 'Szabadság átvitele',
+ 'vacay.carryOverHint': 'Megmaradt szabadságnapok automatikus átvitele a következő évre',
+ 'vacay.sharing': 'Megosztás',
+ 'vacay.sharingHint': 'Szabadságterved megosztása más TREK felhasználókkal',
+ 'vacay.owner': 'Tulajdonos',
+ 'vacay.shareEmailPlaceholder': 'TREK felhasználó e-mail címe',
+ 'vacay.shareSuccess': 'Terv sikeresen megosztva',
+ 'vacay.shareError': 'Nem sikerült megosztani a tervet',
+ 'vacay.dissolve': 'Összevonás feloldása',
+ 'vacay.dissolveHint': 'Naptárak újbóli szétválasztása. A bejegyzéseid megmaradnak.',
+ 'vacay.dissolveAction': 'Feloldás',
+ 'vacay.dissolved': 'Naptár szétválasztva',
+ 'vacay.fusedWith': 'Összevonva:',
+ 'vacay.you': 'te',
+ 'vacay.noData': 'Nincs adat',
+ 'vacay.changeColor': 'Szín módosítása',
+ 'vacay.inviteUser': 'Felhasználó meghívása',
+ 'vacay.inviteHint': 'Hívj meg egy másik TREK felhasználót közös szabadságnaptár megosztásához.',
+ 'vacay.selectUser': 'Felhasználó kiválasztása',
+ 'vacay.sendInvite': 'Meghívó küldése',
+ 'vacay.inviteSent': 'Meghívó elküldve',
+ 'vacay.inviteError': 'Nem sikerült elküldeni a meghívót',
+ 'vacay.pending': 'függőben',
+ 'vacay.noUsersAvailable': 'Nincsenek elérhető felhasználók',
+ 'vacay.accept': 'Elfogadás',
+ 'vacay.decline': 'Elutasítás',
+ 'vacay.acceptFusion': 'Elfogadás és összevonás',
+ 'vacay.inviteTitle': 'Összevonási kérelem',
+ 'vacay.inviteWantsToFuse': 'szeretne megosztani veled egy szabadságnaptárat.',
+ 'vacay.fuseInfo1': 'Mindketten látjátok az összes szabadság-bejegyzést egy közös naptárban.',
+ 'vacay.fuseInfo2': 'Mindkét fél létrehozhat és szerkeszthet bejegyzéseket a másik számára.',
+ 'vacay.fuseInfo3': 'Mindkét fél törölhet bejegyzéseket és módosíthatja a szabadságkeretet.',
+ 'vacay.fuseInfo4': 'A beállítások, mint ünnepnapok és céges szabadnapok, közösen érvényesek.',
+ 'vacay.fuseInfo5': 'Az összevonás bármikor feloldható bármelyik fél által. A bejegyzések megmaradnak.',
+ 'nav.myTrips': 'Utazásaim',
+
+ // Atlas bővítmény
+ 'atlas.subtitle': 'Utazási lábnyomod a világban',
+ 'atlas.countries': 'Országok',
+ 'atlas.trips': 'Utazások',
+ 'atlas.places': 'Helyek',
+ 'atlas.unmark': 'Eltávolítás',
+ 'atlas.confirmMark': 'Megjelölöd ezt az országot meglátogatottként?',
+ 'atlas.confirmUnmark': 'Eltávolítod ezt az országot a meglátogatottak listájáról?',
+ 'atlas.markVisited': 'Megjelölés meglátogatottként',
+ 'atlas.markVisitedHint': 'Ország hozzáadása a meglátogatottak listájához',
+ 'atlas.addToBucket': 'Hozzáadás a bakancslistához',
+ 'atlas.addPoi': 'Hely hozzáadása',
+ 'atlas.bucketNamePlaceholder': 'Név (ország, város, hely...)',
+ 'atlas.month': 'Hónap',
+ 'atlas.addToBucketHint': 'Mentés meglátogatni kívánt helyként',
+ 'atlas.bucketWhen': 'Mikor tervezed meglátogatni?',
+ 'atlas.statsTab': 'Statisztikák',
+ 'atlas.bucketTab': 'Bakancslista',
+ 'atlas.addBucket': 'Hozzáadás a bakancslistához',
+ 'atlas.bucketNotesPlaceholder': 'Jegyzetek (opcionális)',
+ 'atlas.bucketEmpty': 'A bakancslistád üres',
+ 'atlas.bucketEmptyHint': 'Adj hozzá helyeket, ahová álmodsz eljutni',
+ 'atlas.days': 'Napok',
+ 'atlas.visitedCountries': 'Meglátogatott országok',
+ 'atlas.cities': 'Városok',
+ 'atlas.noData': 'Még nincsenek utazási adatok',
+ 'atlas.noDataHint': 'Hozz létre egy utazást és adj hozzá helyeket a világtérképhez',
+ 'atlas.lastTrip': 'Utolsó utazás',
+ 'atlas.nextTrip': 'Következő utazás',
+ 'atlas.daysLeft': 'nap van hátra',
+ 'atlas.streak': 'Sorozat',
+ 'atlas.year': 'év',
+ 'atlas.years': 'év',
+ 'atlas.yearInRow': 'egymást követő év',
+ 'atlas.yearsInRow': 'egymást követő év',
+ 'atlas.tripIn': 'utazás',
+ 'atlas.tripsIn': 'utazás',
+ 'atlas.since': 'óta',
+ 'atlas.europe': 'Európa',
+ 'atlas.asia': 'Ázsia',
+ 'atlas.northAmerica': 'É-Amerika',
+ 'atlas.southAmerica': 'D-Amerika',
+ 'atlas.africa': 'Afrika',
+ 'atlas.oceania': 'Óceánia',
+ 'atlas.other': 'Egyéb',
+ 'atlas.firstVisit': 'Első utazás',
+ 'atlas.lastVisitLabel': 'Utolsó utazás',
+ 'atlas.tripSingular': 'Utazás',
+ 'atlas.tripPlural': 'Utazások',
+ 'atlas.placeVisited': 'Meglátogatott hely',
+ 'atlas.placesVisited': 'Meglátogatott helyek',
+
+ // Utazástervező
+ 'trip.tabs.plan': 'Terv',
+ 'trip.tabs.reservations': 'Foglalások',
+ 'trip.tabs.reservationsShort': 'Foglalás',
+ 'trip.tabs.packing': 'Csomagolási lista',
+ 'trip.tabs.packingShort': 'Csomag',
+ 'trip.tabs.budget': 'Költségvetés',
+ 'trip.tabs.files': 'Fájlok',
+ 'trip.loading': 'Utazás betöltése...',
+ 'trip.mobilePlan': 'Tervezés',
+ 'trip.mobilePlaces': 'Helyek',
+ 'trip.toast.placeUpdated': 'Hely frissítve',
+ 'trip.toast.placeAdded': 'Hely hozzáadva',
+ 'trip.toast.placeDeleted': 'Hely törölve',
+ 'trip.toast.selectDay': 'Kérjük, először válassz egy napot',
+ 'trip.toast.assignedToDay': 'Hely hozzárendelve a naphoz',
+ 'trip.toast.reorderError': 'Nem sikerült átrendezni',
+ 'trip.toast.reservationUpdated': 'Foglalás frissítve',
+ 'trip.toast.reservationAdded': 'Foglalás hozzáadva',
+ 'trip.toast.deleted': 'Törölve',
+ 'trip.confirm.deletePlace': 'Biztosan törölni szeretnéd ezt a helyet?',
+
+ // Napi terv oldalsáv
+ 'dayplan.emptyDay': 'Nincs tervezett hely erre a napra',
+ 'dayplan.addNote': 'Jegyzet hozzáadása',
+ 'dayplan.editNote': 'Jegyzet szerkesztése',
+ 'dayplan.noteAdd': 'Jegyzet hozzáadása',
+ 'dayplan.noteEdit': 'Jegyzet szerkesztése',
+ 'dayplan.noteTitle': 'Jegyzet',
+ 'dayplan.noteSubtitle': 'Napi jegyzet',
+ 'dayplan.totalCost': 'Összköltség',
+ 'dayplan.days': 'nap',
+ 'dayplan.dayN': '{n}. nap',
+ 'dayplan.calculating': 'Számítás...',
+ 'dayplan.route': 'Útvonal',
+ 'dayplan.optimize': 'Optimalizálás',
+ 'dayplan.optimized': 'Útvonal optimalizálva',
+ 'dayplan.routeError': 'Nem sikerült kiszámítani az útvonalat',
+ 'dayplan.toast.needTwoPlaces': 'Legalább két hely szükséges az útvonal-optimalizáláshoz',
+ 'dayplan.toast.routeOptimized': 'Útvonal optimalizálva',
+ 'dayplan.toast.noGeoPlaces': 'Nem találhatók koordinátákkal rendelkező helyek az útvonalszámításhoz',
+ 'dayplan.confirmed': 'Megerősítve',
+ 'dayplan.pendingRes': 'Függőben',
+ 'dayplan.pdf': 'PDF',
+ 'dayplan.pdfTooltip': 'Napi terv exportálása PDF-be',
+ 'dayplan.pdfError': 'Nem sikerült a PDF exportálás',
+ 'dayplan.cannotReorderTransport': 'A rögzített időpontú foglalások nem rendezhetők át',
+ 'dayplan.confirmRemoveTimeTitle': 'Időpont eltávolítása?',
+ 'dayplan.confirmRemoveTimeBody': 'Ennek a helynek rögzített időpontja van ({time}). Az áthelyezéssel az időpont eltávolítódik és szabad rendezés válik lehetővé.',
+ 'dayplan.confirmRemoveTimeAction': 'Időpont eltávolítása és áthelyezés',
+ 'dayplan.cannotDropOnTimed': 'Elemek nem helyezhetők rögzített időpontú bejegyzések közé',
+ 'dayplan.cannotBreakChronology': 'Ez megbontaná az időzített elemek és foglalások időrendi sorrendjét',
+
+ // Helyek oldalsáv
+ 'places.addPlace': 'Hely/Tevékenység hozzáadása',
+ 'places.importGpx': 'GPX importálás',
+ 'places.gpxImported': '{count} hely importálva GPX-ből',
+ 'places.urlResolved': 'Hely importálva URL-ből',
+ 'places.gpxError': 'GPX importálás sikertelen',
+ 'places.assignToDay': 'Melyik naphoz adod?',
+ 'places.all': 'Összes',
+ 'places.unplanned': 'Nem tervezett',
+ 'places.search': 'Helyek keresése...',
+ 'places.allCategories': 'Összes kategória',
+ 'places.categoriesSelected': 'kategória',
+ 'places.clearFilter': 'Szűrő törlése',
+ 'places.count': '{count} hely',
+ 'places.countSingular': '1 hely',
+ 'places.allPlanned': 'Minden hely be van tervezve',
+ 'places.noneFound': 'Nem találhatók helyek',
+ 'places.editPlace': 'Hely szerkesztése',
+ 'places.formName': 'Név',
+ 'places.formNamePlaceholder': 'pl. Eiffel-torony',
+ 'places.formDescription': 'Leírás',
+ 'places.formDescriptionPlaceholder': 'Rövid leírás...',
+ 'places.formAddress': 'Cím',
+ 'places.formAddressPlaceholder': 'Utca, Város, Ország',
+ 'places.formLat': 'Szélességi fok (pl. 48.8566)',
+ 'places.formLng': 'Hosszúsági fok (pl. 2.3522)',
+ 'places.formCategory': 'Kategória',
+ 'places.noCategory': 'Nincs kategória',
+ 'places.categoryNamePlaceholder': 'Kategória neve',
+ 'places.formTime': 'Időpont',
+ 'places.startTime': 'Kezdés',
+ 'places.endTime': 'Befejezés',
+ 'places.endTimeBeforeStart': 'A befejezési idő a kezdési idő előtt van',
+ 'places.timeCollision': 'Időbeli átfedés:',
+ 'places.formWebsite': 'Weboldal',
+ 'places.formNotesPlaceholder': 'Személyes jegyzetek...',
+ 'places.formReservation': 'Foglalás',
+ 'places.reservationNotesPlaceholder': 'Foglalási jegyzetek, visszaigazolási szám...',
+ 'places.mapsSearchPlaceholder': 'Helyek keresése...',
+ 'places.mapsSearchError': 'Helykeresés sikertelen.',
+ 'places.osmHint': 'OpenStreetMap keresés aktív (képek, nyitvatartás és értékelések nélkül). Bővített adatokhoz add meg a Google API kulcsot a beállításokban.',
+ 'places.osmActive': 'Keresés OpenStreetMap-en keresztül (képek, értékelések és nyitvatartás nélkül). Bővített adatokhoz add meg a Google API kulcsot a beállításokban.',
+ 'places.categoryCreateError': 'Nem sikerült létrehozni a kategóriát',
+ 'places.nameRequired': 'Kérjük, adj meg egy nevet',
+ 'places.saveError': 'Nem sikerült menteni',
+ // Hely részletek
+ 'inspector.opened': 'Nyitva',
+ 'inspector.closed': 'Zárva',
+ 'inspector.openingHours': 'Nyitvatartás',
+ 'inspector.showHours': 'Nyitvatartás megjelenítése',
+ 'inspector.files': 'Fájlok',
+ 'inspector.filesCount': '{count} fájl',
+ 'inspector.removeFromDay': 'Eltávolítás a napról',
+ 'inspector.addToDay': 'Hozzáadás a naphoz',
+ 'inspector.confirmedRes': 'Megerősített foglalás',
+ 'inspector.pendingRes': 'Függőben lévő foglalás',
+ 'inspector.google': 'Megnyitás a Google Térképben',
+ 'inspector.website': 'Weboldal megnyitása',
+ 'inspector.addRes': 'Foglalás',
+ 'inspector.editRes': 'Foglalás szerkesztése',
+ 'inspector.participants': 'Résztvevők',
+
+ // Foglalások
+ 'reservations.title': 'Foglalások',
+ 'reservations.empty': 'Még nincsenek foglalások',
+ 'reservations.emptyHint': 'Adj hozzá foglalásokat repülőkhöz, szállodákhoz és egyebekhez',
+ 'reservations.add': 'Foglalás hozzáadása',
+ 'reservations.addManual': 'Kézi foglalás',
+ 'reservations.placeHint': 'Tipp: A foglalásokat legjobb közvetlenül egy helyről létrehozni, hogy összekapcsolódjon a napi tervvel.',
+ 'reservations.confirmed': 'Megerősítve',
+ 'reservations.pending': 'Függőben',
+ 'reservations.summary': '{confirmed} megerősítve, {pending} függőben',
+ 'reservations.fromPlan': 'Tervből',
+ 'reservations.showFiles': 'Fájlok megjelenítése',
+ 'reservations.editTitle': 'Foglalás szerkesztése',
+ 'reservations.status': 'Állapot',
+ 'reservations.datetime': 'Dátum és idő',
+ 'reservations.startTime': 'Kezdési idő',
+ 'reservations.endTime': 'Befejezési idő',
+ 'reservations.date': 'Dátum',
+ 'reservations.time': 'Időpont',
+ 'reservations.timeAlt': 'Időpont (alternatív, pl. 19:30)',
+ 'reservations.linkExisting': 'Meglévő fájl csatolása',
+ 'reservations.notes': 'Jegyzetek',
+ 'reservations.notesPlaceholder': 'További jegyzetek...',
+ 'reservations.meta.airline': 'Légitársaság',
+ 'reservations.meta.flightNumber': 'Járatszám',
+ 'reservations.meta.from': 'Honnan',
+ 'reservations.meta.to': 'Hová',
+ 'reservations.meta.trainNumber': 'Vonatszám',
+ 'reservations.meta.platform': 'Vágány',
+ 'reservations.meta.seat': 'Ülés',
+ 'reservations.meta.checkIn': 'Bejelentkezés',
+ 'reservations.meta.checkOut': 'Kijelentkezés',
+ 'reservations.meta.linkAccommodation': 'Szállás',
+ 'reservations.meta.pickAccommodation': 'Szállás hozzárendelése',
+ 'reservations.meta.noAccommodation': 'Nincs',
+ 'reservations.meta.hotelPlace': 'Szálloda',
+ 'reservations.meta.pickHotel': 'Szálloda kiválasztása',
+ 'reservations.meta.fromDay': 'Ettől',
+ 'reservations.meta.toDay': 'Eddig',
+ 'reservations.meta.selectDay': 'Nap kiválasztása',
+ 'reservations.type.flight': 'Repülő',
+ 'reservations.type.hotel': 'Szálloda',
+ 'reservations.type.restaurant': 'Étterem',
+ 'reservations.type.train': 'Vonat',
+ 'reservations.type.car': 'Autóbérlés',
+ 'reservations.type.cruise': 'Hajóút',
+ 'reservations.type.event': 'Esemény',
+ 'reservations.type.tour': 'Túra',
+ 'reservations.type.other': 'Egyéb',
+ 'reservations.confirm.delete': 'Biztosan törölni szeretnéd a(z) "{name}" foglalást?',
+ 'reservations.confirm.deleteTitle': 'Foglalás törlése?',
+ 'reservations.confirm.deleteBody': '"{name}" véglegesen törlődik.',
+ 'reservations.toast.updated': 'Foglalás frissítve',
+ 'reservations.toast.removed': 'Foglalás törölve',
+ 'reservations.toast.fileUploaded': 'Fájl feltöltve',
+ 'reservations.toast.uploadError': 'Feltöltés sikertelen',
+ 'reservations.newTitle': 'Új foglalás',
+ 'reservations.bookingType': 'Foglalás típusa',
+ 'reservations.titleLabel': 'Cím',
+ 'reservations.titlePlaceholder': 'pl. Lufthansa LH123, Hotel Adlon, ...',
+ 'reservations.locationAddress': 'Helyszín / Cím',
+ 'reservations.locationPlaceholder': 'Cím, Repülőtér, Szálloda...',
+ 'reservations.confirmationCode': 'Foglalási kód',
+ 'reservations.confirmationPlaceholder': 'pl. ABC12345',
+ 'reservations.day': 'Nap',
+ 'reservations.noDay': 'Nincs nap',
+ 'reservations.place': 'Hely',
+ 'reservations.noPlace': 'Nincs hely',
+ 'reservations.pendingSave': 'mentés…',
+ 'reservations.uploading': 'Feltöltés...',
+ 'reservations.attachFile': 'Fájl csatolása',
+ 'reservations.toast.saveError': 'Nem sikerült menteni',
+ 'reservations.toast.updateError': 'Nem sikerült frissíteni',
+ 'reservations.toast.deleteError': 'Nem sikerült törölni',
+ 'reservations.confirm.remove': '"{name}" foglalás eltávolítása?',
+ 'reservations.linkAssignment': 'Összekapcsolás napi tervvel',
+ 'reservations.pickAssignment': 'Válassz hozzárendelést a tervedből...',
+ 'reservations.noAssignment': 'Nincs összekapcsolás (önálló)',
+
+ // Költségvetés
+ 'budget.title': 'Költségvetés',
+ 'budget.emptyTitle': 'Még nincs költségvetés létrehozva',
+ 'budget.emptyText': 'Hozz létre kategóriákat és bejegyzéseket az utazási költségvetés tervezéséhez',
+ 'budget.emptyPlaceholder': 'Kategória neve...',
+ 'budget.createCategory': 'Kategória létrehozása',
+ 'budget.category': 'Kategória',
+ 'budget.categoryName': 'Kategória neve',
+ 'budget.table.name': 'Név',
+ 'budget.table.total': 'Összesen',
+ 'budget.table.persons': 'Személyek',
+ 'budget.table.days': 'nap',
+ 'budget.table.perPerson': 'Személyenként',
+ 'budget.table.perDay': 'Naponta',
+ 'budget.table.perPersonDay': 'Fő / Nap',
+ 'budget.table.note': 'Megjegyzés',
+ 'budget.newEntry': 'Új bejegyzés',
+ 'budget.defaultEntry': 'Új bejegyzés',
+ 'budget.defaultCategory': 'Új kategória',
+ 'budget.total': 'Összesen',
+ 'budget.totalBudget': 'Teljes költségvetés',
+ 'budget.byCategory': 'Kategóriánként',
+ 'budget.editTooltip': 'Kattints a szerkesztéshez',
+ 'budget.confirm.deleteCategory': 'Biztosan törölni szeretnéd a(z) "{name}" kategóriát {count} bejegyzéssel?',
+ 'budget.deleteCategory': 'Kategória törlése',
+ 'budget.perPerson': 'Személyenként',
+ 'budget.paid': 'Fizetve',
+ 'budget.open': 'Nyitott',
+ 'budget.noMembers': 'Nincsenek résztvevők hozzárendelve',
+ 'budget.settlement': 'Elszámolás',
+ 'budget.settlementInfo': 'Kattints egy tag avatárjára egy költségvetési tételen a zöld jelöléshez — ez azt jelenti, hogy fizetett. Az elszámolás ezután mutatja, ki kinek mennyivel tartozik.',
+ 'budget.netBalances': 'Nettó egyenlegek',
+
+ // Fájlok
+ 'files.title': 'Fájlok',
+ 'files.count': '{count} fájl',
+ 'files.countSingular': '1 fájl',
+ 'files.uploaded': '{count} feltöltve',
+ 'files.uploadError': 'Feltöltés sikertelen',
+ 'files.dropzone': 'Húzd ide a fájlokat',
+ 'files.dropzoneHint': 'vagy kattints a böngészéshez',
+ 'files.allowedTypes': 'Képek, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB',
+ 'files.uploading': 'Feltöltés...',
+ 'files.filterAll': 'Összes',
+ 'files.filterPdf': 'PDF-ek',
+ 'files.filterImages': 'Képek',
+ 'files.filterDocs': 'Dokumentumok',
+ 'files.filterCollab': 'Közös jegyzetek',
+ 'files.sourceCollab': 'Közös jegyzetekből',
+ 'files.empty': 'Még nincsenek fájlok',
+ 'files.emptyHint': 'Tölts fel fájlokat az utazásodhoz',
+ 'files.openTab': 'Megnyitás új lapon',
+ 'files.confirm.delete': 'Biztosan törölni szeretnéd ezt a fájlt?',
+ 'files.toast.deleted': 'Fájl törölve',
+ 'files.toast.deleteError': 'Nem sikerült törölni a fájlt',
+ 'files.sourcePlan': 'Napi terv',
+ 'files.sourceBooking': 'Foglalás',
+ 'files.attach': 'Csatolás',
+ 'files.pasteHint': 'Képeket a vágólapról is beillesztheted (Ctrl+V)',
+ 'files.trash': 'Kuka',
+ 'files.trashEmpty': 'A kuka üres',
+ 'files.emptyTrash': 'Kuka ürítése',
+ 'files.restore': 'Visszaállítás',
+ 'files.star': 'Csillag',
+ 'files.unstar': 'Csillag eltávolítása',
+ 'files.assign': 'Hozzárendelés',
+ 'files.assignTitle': 'Fájl hozzárendelése',
+ 'files.assignPlace': 'Hely',
+ 'files.assignBooking': 'Foglalás',
+ 'files.unassigned': 'Nincs hozzárendelve',
+ 'files.unlink': 'Kapcsolat eltávolítása',
+ 'files.toast.trashed': 'Kukába helyezve',
+ 'files.toast.restored': 'Fájl visszaállítva',
+ 'files.toast.trashEmptied': 'Kuka kiürítve',
+ 'files.toast.assigned': 'Fájl hozzárendelve',
+ 'files.toast.assignError': 'Hozzárendelés sikertelen',
+ 'files.toast.restoreError': 'Visszaállítás sikertelen',
+ 'files.confirm.permanentDelete': 'Véglegesen törlöd ezt a fájlt? Ez nem vonható vissza.',
+ 'files.confirm.emptyTrash': 'Véglegesen törlöd az összes kukába helyezett fájlt? Ez nem vonható vissza.',
+ 'files.noteLabel': 'Megjegyzés',
+ 'files.notePlaceholder': 'Megjegyzés hozzáadása...',
+
+ // Csomagolás
+ 'packing.title': 'Csomagolási lista',
+ 'packing.empty': 'A csomagolási lista üres',
+ 'packing.import': 'Importálás',
+ 'packing.importTitle': 'Csomagolási lista importálása',
+ 'packing.importHint': 'Soronként egy tétel. Formátum: Kategória, Név, Súly g-ban (opcionális), Táska (opcionális), checked/unchecked (opcionális)',
+ 'packing.importPlaceholder': 'Tisztálkodás, Fogkefe\nRuházat, Pólók, 200\nDokumentumok, Útlevél, , Kézipoggyász\nElektronika, Töltő, 50, Bőrönd, checked',
+ 'packing.importCsv': 'CSV/TXT betöltése',
+ 'packing.importAction': '{count} importálása',
+ 'packing.importSuccess': '{count} tétel importálva',
+ 'packing.importError': 'Importálás sikertelen',
+ 'packing.importEmpty': 'Nincsenek importálható tételek',
+ 'packing.progress': '{packed} / {total} becsomagolva ({percent}%)',
+ 'packing.clearChecked': '{count} kipipált eltávolítása',
+ 'packing.clearCheckedShort': '{count} eltávolítása',
+ 'packing.suggestions': 'Javaslatok',
+ 'packing.suggestionsTitle': 'Javaslatok hozzáadása',
+ 'packing.allSuggested': 'Minden javaslat hozzáadva',
+ 'packing.allPacked': 'Minden be van csomagolva!',
+ 'packing.addPlaceholder': 'Új tárgy hozzáadása...',
+ 'packing.categoryPlaceholder': 'Kategória...',
+ 'packing.filterAll': 'Összes',
+ 'packing.filterOpen': 'Nyitott',
+ 'packing.filterDone': 'Kész',
+ 'packing.emptyTitle': 'A csomagolási lista üres',
+ 'packing.emptyHint': 'Adj hozzá tárgyakat vagy használd a javaslatokat',
+ 'packing.emptyFiltered': 'Nincs elem ebben a szűrőben',
+ 'packing.menuRename': 'Átnevezés',
+ 'packing.menuCheckAll': 'Összes kipipálása',
+ 'packing.menuUncheckAll': 'Összes jelölés törlése',
+ 'packing.menuDeleteCat': 'Kategória törlése',
+ 'packing.assignUser': 'Felhasználó hozzárendelése',
+ 'packing.noMembers': 'Nincsenek utazási tagok',
+ 'packing.addItem': 'Tétel hozzáadása',
+ 'packing.addItemPlaceholder': 'Tétel neve...',
+ 'packing.addCategory': 'Kategória hozzáadása',
+ 'packing.newCategoryPlaceholder': 'Kategória neve (pl. Ruházat)',
+ 'packing.applyTemplate': 'Sablon alkalmazása',
+ 'packing.template': 'Sablon',
+ 'packing.templateApplied': '{count} tétel hozzáadva a sablonból',
+ 'packing.templateError': 'Nem sikerült alkalmazni a sablont',
+ 'packing.bags': 'Táskák',
+ 'packing.noBag': 'Nincs hozzárendelve',
+ 'packing.totalWeight': 'Összsúly',
+ 'packing.bagName': 'Táska neve...',
+ 'packing.addBag': 'Táska hozzáadása',
+ 'packing.changeCategory': 'Kategória módosítása',
+ 'packing.confirm.clearChecked': 'Biztosan el szeretnéd távolítani a(z) {count} kipipált tárgyat?',
+ 'packing.confirm.deleteCat': 'Biztosan törölni szeretnéd a(z) "{name}" kategóriát {count} tárggyal?',
+ 'packing.defaultCategory': 'Egyéb',
+ 'packing.toast.saveError': 'Nem sikerült menteni',
+ 'packing.toast.deleteError': 'Nem sikerült törölni',
+ 'packing.toast.renameError': 'Nem sikerült átnevezni',
+ 'packing.toast.addError': 'Nem sikerült hozzáadni',
+
+ // Csomagolási javaslatok
+ 'packing.suggestions.items': [
+ { name: 'Útlevél', category: 'Dokumentumok' },
+ { name: 'Személyi igazolvány', category: 'Dokumentumok' },
+ { name: 'Utazási biztosítás', category: 'Dokumentumok' },
+ { name: 'Repülőjegyek', category: 'Dokumentumok' },
+ { name: 'Bankkártya', category: 'Pénzügyek' },
+ { name: 'Készpénz', category: 'Pénzügyek' },
+ { name: 'Vízum', category: 'Dokumentumok' },
+ { name: 'Pólók', category: 'Ruházat' },
+ { name: 'Nadrágok', category: 'Ruházat' },
+ { name: 'Fehérnemű', category: 'Ruházat' },
+ { name: 'Zoknik', category: 'Ruházat' },
+ { name: 'Kabát', category: 'Ruházat' },
+ { name: 'Hálóruha', category: 'Ruházat' },
+ { name: 'Fürdőruha', category: 'Ruházat' },
+ { name: 'Esőkabát', category: 'Ruházat' },
+ { name: 'Kényelmes cipő', category: 'Ruházat' },
+ { name: 'Fogkefe', category: 'Tisztálkodás' },
+ { name: 'Fogkrém', category: 'Tisztálkodás' },
+ { name: 'Sampon', category: 'Tisztálkodás' },
+ { name: 'Dezodor', category: 'Tisztálkodás' },
+ { name: 'Naptej', category: 'Tisztálkodás' },
+ { name: 'Borotva', category: 'Tisztálkodás' },
+ { name: 'Töltő', category: 'Elektronika' },
+ { name: 'Powerbank', category: 'Elektronika' },
+ { name: 'Fejhallgató', category: 'Elektronika' },
+ { name: 'Úti adapter', category: 'Elektronika' },
+ { name: 'Fényképezőgép', category: 'Elektronika' },
+ { name: 'Fájdalomcsillapító', category: 'Egészség' },
+ { name: 'Ragtapasz', category: 'Egészség' },
+ { name: 'Fertőtlenítőszer', category: 'Egészség' },
+ ],
+
+ // Tagok / Megosztás
+ 'members.shareTrip': 'Utazás megosztása',
+ 'members.inviteUser': 'Felhasználó meghívása',
+ 'members.selectUser': 'Felhasználó kiválasztása…',
+ 'members.invite': 'Meghívás',
+ 'members.allHaveAccess': 'Minden felhasználónak már van hozzáférése.',
+ 'members.access': 'Hozzáférés',
+ 'members.person': 'személy',
+ 'members.persons': 'személy',
+ 'members.you': 'te',
+ 'members.owner': 'Tulajdonos',
+ 'members.leaveTrip': 'Utazás elhagyása',
+ 'members.removeAccess': 'Hozzáférés eltávolítása',
+ 'members.confirmLeave': 'Elhagyod az utazást? Elveszíted a hozzáférést.',
+ 'members.confirmRemove': 'Eltávolítod a hozzáférést ettől a felhasználótól?',
+ 'members.loadError': 'Nem sikerült betölteni a tagokat',
+ 'members.added': 'hozzáadva',
+ 'members.addError': 'Nem sikerült hozzáadni',
+ 'members.removed': 'Tag eltávolítva',
+ 'members.removeError': 'Nem sikerült eltávolítani',
+
+ // Kategóriák (Admin)
+ 'categories.title': 'Kategóriák',
+ 'categories.subtitle': 'Helyek kategóriáinak kezelése',
+ 'categories.new': 'Új kategória',
+ 'categories.empty': 'Még nincsenek kategóriák',
+ 'categories.namePlaceholder': 'Kategória neve',
+ 'categories.icon': 'Ikon',
+ 'categories.color': 'Szín',
+ 'categories.customColor': 'Egyéni szín kiválasztása',
+ 'categories.preview': 'Előnézet',
+ 'categories.defaultName': 'Kategória',
+ 'categories.update': 'Frissítés',
+ 'categories.create': 'Létrehozás',
+ 'categories.confirm.delete': 'Kategória törlése? Az ebben a kategóriában lévő helyek nem törlődnek.',
+ 'categories.toast.loadError': 'Nem sikerült betölteni a kategóriákat',
+ 'categories.toast.nameRequired': 'Kérjük, adj meg egy nevet',
+ 'categories.toast.updated': 'Kategória frissítve',
+ 'categories.toast.created': 'Kategória létrehozva',
+ 'categories.toast.saveError': 'Nem sikerült menteni',
+ 'categories.toast.deleted': 'Kategória törölve',
+ 'categories.toast.deleteError': 'Nem sikerült törölni',
+
+ // Biztonsági mentés (Admin)
+ 'backup.title': 'Adatmentés',
+ 'backup.subtitle': 'Adatbázis és minden feltöltött fájl',
+ 'backup.refresh': 'Frissítés',
+ 'backup.upload': 'Mentés feltöltése',
+ 'backup.uploading': 'Feltöltés…',
+ 'backup.create': 'Mentés készítése',
+ 'backup.creating': 'Készítés…',
+ 'backup.empty': 'Még nincsenek mentések',
+ 'backup.createFirst': 'Első mentés készítése',
+ 'backup.download': 'Letöltés',
+ 'backup.restore': 'Visszaállítás',
+ 'backup.confirm.restore': '"{name}" mentés visszaállítása?\n\nMinden jelenlegi adat a mentéssel lesz helyettesítve.',
+ 'backup.confirm.uploadRestore': '"{name}" mentésfájl feltöltése és visszaállítása?\n\nMinden jelenlegi adat felülíródik.',
+ 'backup.confirm.delete': '"{name}" mentés törlése?',
+ 'backup.toast.loadError': 'Nem sikerült betölteni a mentéseket',
+ 'backup.toast.created': 'Mentés sikeresen létrehozva',
+ 'backup.toast.createError': 'Nem sikerült létrehozni a mentést',
+ 'backup.toast.restored': 'Mentés visszaállítva. Az oldal újratöltődik…',
+ 'backup.toast.restoreError': 'Nem sikerült visszaállítani',
+ 'backup.toast.uploadError': 'Nem sikerült feltölteni',
+ 'backup.toast.deleted': 'Mentés törölve',
+ 'backup.toast.deleteError': 'Nem sikerült törölni',
+ 'backup.toast.downloadError': 'Letöltés sikertelen',
+ 'backup.toast.settingsSaved': 'Automatikus mentés beállításai mentve',
+ 'backup.toast.settingsError': 'Nem sikerült menteni a beállításokat',
+ 'backup.auto.title': 'Automatikus mentés',
+ 'backup.auto.subtitle': 'Automatikus mentés ütemezés szerint',
+ 'backup.auto.enable': 'Automatikus mentés engedélyezése',
+ 'backup.auto.enableHint': 'A mentések automatikusan készülnek a választott ütemezés szerint',
+ 'backup.auto.interval': 'Időköz',
+ 'backup.auto.hour': 'Futtatás időpontja',
+ 'backup.auto.hourHint': 'Szerver helyi ideje ({format} formátum)',
+ 'backup.auto.dayOfWeek': 'A hét napja',
+ 'backup.auto.dayOfMonth': 'A hónap napja',
+ 'backup.auto.dayOfMonthHint': '1–28-ra korlátozva az összes hónappal való kompatibilitás érdekében',
+ 'backup.auto.scheduleSummary': 'Ütemezés',
+ 'backup.auto.summaryDaily': 'Minden nap {hour}:00-kor',
+ 'backup.auto.summaryWeekly': 'Minden {day} {hour}:00-kor',
+ 'backup.auto.summaryMonthly': 'Minden hónap {day}. napján {hour}:00-kor',
+ 'backup.auto.envLocked': 'Docker',
+ 'backup.auto.envLockedHint': 'Az automatikus mentés Docker környezeti változókon keresztül van konfigurálva. A beállítások módosításához frissítsd a docker-compose.yml fájlt és indítsd újra a konténert.',
+ 'backup.auto.copyEnv': 'Docker env változók másolása',
+ 'backup.auto.envCopied': 'Docker env változók vágólapra másolva',
+ 'backup.auto.keepLabel': 'Régi mentések törlése ennyi idő után',
+ 'backup.dow.sunday': 'Va',
+ 'backup.dow.monday': 'Hé',
+ 'backup.dow.tuesday': 'Ke',
+ 'backup.dow.wednesday': 'Sze',
+ 'backup.dow.thursday': 'Csü',
+ 'backup.dow.friday': 'Pé',
+ 'backup.dow.saturday': 'Szo',
+ 'backup.interval.hourly': 'Óránként',
+ 'backup.interval.daily': 'Naponta',
+ 'backup.interval.weekly': 'Hetente',
+ 'backup.interval.monthly': 'Havonta',
+ 'backup.keep.1day': '1 nap',
+ 'backup.keep.3days': '3 nap',
+ 'backup.keep.7days': '7 nap',
+ 'backup.keep.14days': '14 nap',
+ 'backup.keep.30days': '30 nap',
+ 'backup.keep.forever': 'Örökre megőrzés',
+
+ // Fotók
+ 'photos.allDays': 'Minden nap',
+ 'photos.noPhotos': 'Még nincsenek fotók',
+ 'photos.uploadHint': 'Töltsd fel az úti fotóidat',
+ 'photos.clickToSelect': 'vagy kattints a kiválasztáshoz',
+ 'photos.linkPlace': 'Hely társítása',
+ 'photos.noPlace': 'Nincs hely',
+ 'photos.uploadN': '{n} fotó feltöltése',
+
+ // Mentés visszaállítása modal
+ 'backup.restoreConfirmTitle': 'Mentés visszaállítása?',
+ 'backup.restoreWarning': 'Minden jelenlegi adat (utazások, helyek, felhasználók, feltöltések) véglegesen lecserélődik a mentéssel. Ez a művelet nem vonható vissza.',
+ 'backup.restoreTip': 'Tipp: Készíts mentést a jelenlegi állapotról a visszaállítás előtt.',
+ 'backup.restoreConfirm': 'Igen, visszaállítás',
+
+ // PDF
+ 'pdf.travelPlan': 'Utazási terv',
+ 'pdf.planned': 'Tervezett',
+ 'pdf.costLabel': 'Költség',
+ 'pdf.preview': 'PDF előnézet',
+ 'pdf.saveAsPdf': 'Mentés PDF-ként',
+
+ // Tervező
+ 'planner.places': 'Helyek',
+ 'planner.bookings': 'Foglalások',
+ 'planner.packingList': 'Csomagolási lista',
+ 'planner.documents': 'Dokumentumok',
+ 'planner.dayPlan': 'Napi terv',
+ 'planner.reservations': 'Foglalások',
+ 'planner.minTwoPlaces': 'Legalább 2 koordinátákkal rendelkező hely szükséges',
+ 'planner.noGeoPlaces': 'Nincsenek koordinátákkal rendelkező helyek',
+ 'planner.routeCalculated': 'Útvonal kiszámítva',
+ 'planner.routeCalcFailed': 'Nem sikerült kiszámítani az útvonalat',
+ 'planner.routeError': 'Hiba az útvonalszámítás során',
+ 'planner.routeOptimized': 'Útvonal optimalizálva',
+ 'planner.reservationUpdated': 'Foglalás frissítve',
+ 'planner.reservationAdded': 'Foglalás hozzáadva',
+ 'planner.confirmDeleteReservation': 'Foglalás törlése?',
+ 'planner.reservationDeleted': 'Foglalás törölve',
+ 'planner.days': 'nap',
+ 'planner.allPlaces': 'Összes hely',
+ 'planner.totalPlaces': 'Összesen {n} hely',
+ 'planner.noDaysPlanned': 'Még nincsenek napok tervezve',
+ 'planner.editTrip': 'Utazás szerkesztése \u2192',
+ 'planner.placeOne': '1 hely',
+ 'planner.placeN': '{n} hely',
+ 'planner.addNote': 'Jegyzet hozzáadása',
+ 'planner.noEntries': 'Nincsenek bejegyzések erre a napra',
+ 'planner.addPlace': 'Hely/tevékenység hozzáadása',
+ 'planner.addPlaceShort': '+ Hely/tevékenység hozzáadása',
+ 'planner.resPending': 'Foglalás függőben · ',
+ 'planner.resConfirmed': 'Foglalás megerősítve · ',
+ 'planner.notePlaceholder': 'Jegyzet\u2026',
+ 'planner.noteTimePlaceholder': 'Időpont (opcionális)',
+ 'planner.noteExamplePlaceholder': 'pl. S3 14:30-kor a főpályaudvarról, komp a 7. mólóról, ebédszünet\u2026',
+ 'planner.totalCost': 'Összköltség',
+ 'planner.searchPlaces': 'Helyek keresése\u2026',
+ 'planner.allCategories': 'Összes kategória',
+ 'planner.noPlacesFound': 'Nem találhatók helyek',
+ 'planner.addFirstPlace': 'Első hely hozzáadása',
+ 'planner.noReservations': 'Nincsenek foglalások',
+ 'planner.addFirstReservation': 'Első foglalás hozzáadása',
+ 'planner.new': 'Új',
+ 'planner.addToDay': '+ Nap',
+ 'planner.calculating': 'Számítás\u2026',
+ 'planner.route': 'Útvonal',
+ 'planner.optimize': 'Optimalizálás',
+ 'planner.openGoogleMaps': 'Megnyitás a Google Térképben',
+ 'planner.selectDayHint': 'Válassz egy napot a bal oldali listából a napi terv megtekintéséhez',
+ 'planner.noPlacesForDay': 'Még nincsenek helyek erre a napra',
+ 'planner.addPlacesLink': 'Helyek hozzáadása \u2192',
+ 'planner.minTotal': 'perc összesen',
+ 'planner.noReservation': 'Nincs foglalás',
+ 'planner.removeFromDay': 'Eltávolítás a napról',
+ 'planner.addToThisDay': 'Hozzáadás a naphoz',
+ 'planner.overview': 'Áttekintés',
+ 'planner.noDays': 'Még nincsenek napok',
+ 'planner.editTripToAddDays': 'Szerkeszd az utazást napok hozzáadásához',
+ 'planner.dayCount': '{n} nap',
+ 'planner.clickToUnlock': 'Kattints a feloldáshoz',
+ 'planner.keepPosition': 'Pozíció megtartása útvonal-optimalizálás során',
+ 'planner.dayDetails': 'Nap részletei',
+ 'planner.dayN': '{n}. nap',
+
+ // Irányítópult statisztikák
+ 'stats.countries': 'Országok',
+ 'stats.cities': 'Városok',
+ 'stats.trips': 'Utazások',
+ 'stats.places': 'Helyek',
+ 'stats.worldProgress': 'Világ felfedezése',
+ 'stats.visited': 'meglátogatott',
+ 'stats.remaining': 'hátralévő',
+ 'stats.visitedCountries': 'Meglátogatott országok',
+
+ // Nap részletei panel
+ 'day.precipProb': 'Csapadékvalószínűség',
+ 'day.precipitation': 'Csapadék',
+ 'day.wind': 'Szél',
+ 'day.sunrise': 'Napkelte',
+ 'day.sunset': 'Napnyugta',
+ 'day.hourlyForecast': 'Óránkénti előrejelzés',
+ 'day.climateHint': 'Történelmi átlagok — valós előrejelzés a dátum előtti 16 napon belül érhető el.',
+ 'day.noWeather': 'Nem állnak rendelkezésre időjárási adatok. Adj hozzá egy helyet koordinátákkal.',
+ 'day.overview': 'Napi áttekintés',
+ 'day.accommodation': 'Szállás',
+ 'day.addAccommodation': 'Szállás hozzáadása',
+ 'day.hotelDayRange': 'Alkalmazás napokra',
+ 'day.noPlacesForHotel': 'Először adj hozzá helyeket az utazásodhoz',
+ 'day.allDays': 'Összes',
+ 'day.checkIn': 'Bejelentkezés',
+ 'day.checkOut': 'Kijelentkezés',
+ 'day.confirmation': 'Visszaigazolás',
+ 'day.editAccommodation': 'Szállás szerkesztése',
+ 'day.reservations': 'Foglalások',
+
+ // Collab bővítmény
+ 'collab.tabs.chat': 'Csevegés',
+ 'collab.tabs.notes': 'Jegyzetek',
+ 'collab.tabs.polls': 'Szavazások',
+ 'collab.whatsNext.title': 'Mi következik',
+ 'collab.whatsNext.today': 'Ma',
+ 'collab.whatsNext.tomorrow': 'Holnap',
+ 'collab.whatsNext.empty': 'Nincsenek közelgő tevékenységek',
+ 'collab.whatsNext.until': '-ig',
+ 'collab.whatsNext.emptyHint': 'Az időponttal rendelkező tevékenységek itt jelennek meg',
+ 'collab.chat.send': 'Küldés',
+ 'collab.chat.placeholder': 'Üzenet írása...',
+ 'collab.chat.empty': 'Kezdd el a beszélgetést',
+ 'collab.chat.emptyHint': 'Az üzenetek az utazás minden tagjával meg vannak osztva',
+ 'collab.chat.emptyDesc': 'Oszd meg ötleteidet, terveidet és híreidet az utazócsoportoddal',
+ 'collab.chat.today': 'Ma',
+ 'collab.chat.yesterday': 'Tegnap',
+ 'collab.chat.deletedMessage': 'törölt egy üzenetet',
+ 'collab.chat.loadMore': 'Korábbi üzenetek betöltése',
+ 'collab.chat.justNow': 'éppen most',
+ 'collab.chat.minutesAgo': '{n} perce',
+ 'collab.chat.hoursAgo': '{n} órája',
+ 'collab.notes.title': 'Jegyzetek',
+ 'collab.notes.new': 'Új jegyzet',
+ 'collab.notes.empty': 'Még nincsenek jegyzetek',
+ 'collab.notes.emptyHint': 'Rögzítsd az ötleteidet és terveidet',
+ 'collab.notes.all': 'Összes',
+ 'collab.notes.titlePlaceholder': 'Jegyzet címe',
+ 'collab.notes.contentPlaceholder': 'Írj valamit...',
+ 'collab.notes.categoryPlaceholder': 'Kategória',
+ 'collab.notes.newCategory': 'Új kategória...',
+ 'collab.notes.category': 'Kategória',
+ 'collab.notes.noCategory': 'Nincs kategória',
+ 'collab.notes.color': 'Szín',
+ 'collab.notes.save': 'Mentés',
+ 'collab.notes.cancel': 'Mégse',
+ 'collab.notes.edit': 'Szerkesztés',
+ 'collab.notes.delete': 'Törlés',
+ 'collab.notes.pin': 'Kitűzés',
+ 'collab.notes.unpin': 'Kitűzés eltávolítása',
+ 'collab.notes.daysAgo': '{n} napja',
+ 'collab.notes.categorySettings': 'Kategóriák kezelése',
+ 'collab.notes.create': 'Létrehozás',
+ 'collab.notes.website': 'Weboldal',
+ 'collab.notes.websitePlaceholder': 'https://...',
+ 'collab.notes.attachFiles': 'Fájlok csatolása',
+ 'collab.notes.noCategoriesYet': 'Még nincsenek kategóriák',
+ 'collab.notes.emptyDesc': 'Hozz létre egy jegyzetet a kezdéshez',
+ 'collab.polls.title': 'Szavazások',
+ 'collab.polls.new': 'Új szavazás',
+ 'collab.polls.empty': 'Még nincsenek szavazások',
+ 'collab.polls.emptyHint': 'Kérdezd meg a csoportot és szavazzatok együtt',
+ 'collab.polls.question': 'Kérdés',
+ 'collab.polls.questionPlaceholder': 'Mit csináljunk?',
+ 'collab.polls.addOption': 'Opció hozzáadása',
+ 'collab.polls.optionPlaceholder': '{n}. opció',
+ 'collab.polls.create': 'Szavazás létrehozása',
+ 'collab.polls.close': 'Lezárás',
+ 'collab.polls.closed': 'Lezárva',
+ 'collab.polls.votes': '{n} szavazat',
+ 'collab.polls.vote': '{n} szavazat',
+ 'collab.polls.multipleChoice': 'Többszörös választás',
+ 'collab.polls.multiChoice': 'Többszörös választás',
+ 'collab.polls.deadline': 'Határidő',
+ 'collab.polls.option': 'Opció',
+ 'collab.polls.options': 'Opciók',
+ 'collab.polls.delete': 'Törlés',
+ 'collab.polls.closedSection': 'Lezárva',
+
+ // Emlékek / Immich
+ 'memories.title': 'Fotók',
+ 'memories.notConnected': 'Immich nincs csatlakoztatva',
+ 'memories.notConnectedHint': 'Csatlakoztasd az Immich példányodat a Beállításokban, hogy itt lásd az utazási fotóidat.',
+ 'memories.noDates': 'Adj hozzá dátumokat az utazáshoz a fotók betöltéséhez.',
+ 'memories.noPhotos': 'Nem találhatók fotók',
+ 'memories.noPhotosHint': 'Nem találhatók fotók az Immichben erre az utazási időszakra.',
+ 'memories.photosFound': 'fotó',
+ 'memories.fromOthers': 'másoktól',
+ 'memories.sharePhotos': 'Fotók megosztása',
+ 'memories.sharing': 'Megosztás',
+ 'memories.reviewTitle': 'Nézd át a fotóidat',
+ 'memories.reviewHint': 'Kattints a fotókra a megosztásból való kizáráshoz.',
+ 'memories.shareCount': '{count} fotó megosztása',
+ 'memories.immichUrl': 'Immich szerver URL',
+ 'memories.immichApiKey': 'API kulcs',
+ 'memories.testConnection': 'Kapcsolat tesztelése',
+ 'memories.connected': 'Csatlakoztatva',
+ 'memories.disconnected': 'Nincs csatlakoztatva',
+ 'memories.connectionSuccess': 'Csatlakozva az Immichhez',
+ 'memories.connectionError': 'Nem sikerült csatlakozni az Immichhez',
+ 'memories.saved': 'Immich beállítások mentve',
+ 'memories.addPhotos': 'Fotók hozzáadása',
+ 'memories.selectPhotos': 'Fotók kiválasztása az Immichből',
+ 'memories.selectHint': 'Koppints a fotókra a kijelölésükhöz.',
+ 'memories.selected': 'kijelölve',
+ 'memories.addSelected': '{count} fotó hozzáadása',
+ 'memories.alreadyAdded': 'Hozzáadva',
+ 'memories.private': 'Privát',
+ 'memories.stopSharing': 'Megosztás leállítása',
+ 'memories.oldest': 'Legrégebbi elöl',
+ 'memories.newest': 'Legújabb elöl',
+ 'memories.allLocations': 'Összes helyszín',
+ 'memories.tripDates': 'Utazás dátumai',
+ 'memories.allPhotos': 'Összes fotó',
+ 'memories.confirmShareTitle': 'Megosztás az utazótársakkal?',
+ 'memories.confirmShareHint': '{count} fotó lesz látható az utazás összes tagja számára. Később egyenként is priváttá teheted őket.',
+ 'memories.confirmShareButton': 'Fotók megosztása',
+}
+
+export default hu
diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts
new file mode 100644
index 0000000..a7ae949
--- /dev/null
+++ b/client/src/i18n/translations/it.ts
@@ -0,0 +1,1395 @@
+const it: Record = {
+ // Common
+ 'common.save': 'Salva',
+ 'common.cancel': 'Annulla',
+ 'common.delete': 'Elimina',
+ 'common.edit': 'Modifica',
+ 'common.add': 'Aggiungi',
+ 'common.loading': 'Caricamento...',
+ 'common.error': 'Errore',
+ 'common.back': 'Indietro',
+ 'common.all': 'Tutti',
+ 'common.close': 'Chiudi',
+ 'common.open': 'Apri',
+ 'common.upload': 'Carica',
+ 'common.search': 'Cerca',
+ 'common.confirm': 'Conferma',
+ 'common.ok': 'OK',
+ 'common.yes': 'Sì',
+ 'common.no': 'No',
+ 'common.or': 'o',
+ 'common.none': 'Nessuno',
+ 'common.date': 'Data',
+ 'common.rename': 'Rinomina',
+ 'common.name': 'Nome',
+ 'common.email': 'Email',
+ 'common.password': 'Password',
+ 'common.saving': 'Salvataggio...',
+ 'common.update': 'Aggiorna',
+ 'common.change': 'Cambia',
+ 'common.uploading': 'Caricamento…',
+ 'common.backToPlanning': 'Torna al Programma',
+ 'common.reset': 'Reimposta',
+
+ // Navbar
+ 'nav.trip': 'Viaggio',
+ 'nav.share': 'Condividi',
+ 'nav.settings': 'Impostazioni',
+ 'nav.admin': 'Amministrazione',
+ 'nav.logout': 'Esci',
+ 'nav.lightMode': 'Modalità chiara',
+ 'nav.darkMode': 'Modalità scura',
+ 'nav.autoMode': 'Modalità automatica',
+ 'nav.administrator': 'Amministratore',
+
+ // Dashboard
+ 'dashboard.title': 'I miei Viaggi',
+ 'dashboard.subtitle.loading': 'Caricamento viaggi...',
+ 'dashboard.subtitle.trips': '{count} viaggi ({archived} archiviati)',
+ 'dashboard.subtitle.empty': 'Inizia il tuo primo viaggio',
+ 'dashboard.subtitle.activeOne': '{count} viaggio attivo',
+ 'dashboard.subtitle.activeMany': '{count} viaggi attivi',
+ 'dashboard.subtitle.archivedSuffix': ' · {count} archiviati',
+ 'dashboard.newTrip': 'Nuovo Viaggio',
+ 'dashboard.gridView': 'Vista a griglia',
+ 'dashboard.listView': 'Vista a lista',
+ 'dashboard.currency': 'Valuta',
+ 'dashboard.timezone': 'Fusi orari',
+ 'dashboard.localTime': 'Locale',
+ 'dashboard.timezoneCustomTitle': 'Fuso orario personalizzato',
+ 'dashboard.timezoneCustomLabelPlaceholder': 'Etichetta (opzionale)',
+ 'dashboard.timezoneCustomTzPlaceholder': 'es. Europe/Rome',
+ 'dashboard.timezoneCustomAdd': 'Aggiungi',
+ 'dashboard.timezoneCustomErrorEmpty': 'Inserisci un identificatore di fuso orario',
+ 'dashboard.timezoneCustomErrorInvalid': 'Fuso orario non valido. Usa formati come Europe/Rome',
+ 'dashboard.timezoneCustomErrorDuplicate': 'Già aggiunto',
+ 'dashboard.emptyTitle': 'Ancora nessun viaggio',
+ 'dashboard.emptyText': 'Crea il tuo primo viaggio e inizia a programmare!',
+ 'dashboard.emptyButton': 'Crea il primo viaggio',
+ 'dashboard.nextTrip': 'Prossimo Viaggio',
+ 'dashboard.shared': 'Condiviso',
+ 'dashboard.sharedBy': 'Condiviso da {name}',
+ 'dashboard.days': 'Giorni',
+ 'dashboard.places': 'Luoghi',
+ 'dashboard.archive': 'Archivia',
+ 'dashboard.restore': 'Ripristina',
+ 'dashboard.archived': 'Archiviati',
+ 'dashboard.status.ongoing': 'In corso',
+ 'dashboard.status.today': 'Oggi',
+ 'dashboard.status.tomorrow': 'Domani',
+ 'dashboard.status.past': 'Passato',
+ 'dashboard.status.daysLeft': '-{count} giorni',
+ 'dashboard.toast.loadError': 'Impossibile caricare i viaggi',
+ 'dashboard.toast.created': 'Viaggio creato con successo!',
+ 'dashboard.toast.createError': 'Impossibile creare il viaggio',
+ 'dashboard.toast.updated': 'Viaggio aggiornato!',
+ 'dashboard.toast.updateError': 'Impossibile aggiornare il viaggio',
+ 'dashboard.toast.deleted': 'Viaggio eliminato',
+ 'dashboard.toast.deleteError': 'Impossibile eliminare il viaggio',
+ 'dashboard.toast.archived': 'Viaggio archiviato',
+ 'dashboard.toast.archiveError': 'Impossibile archiviare il viaggio',
+ 'dashboard.toast.restored': 'Viaggio ripristinato',
+ 'dashboard.toast.restoreError': 'Impossibile ripristinare il viaggio',
+ 'dashboard.confirm.delete': 'Eliminare il viaggio "{title}"? Tutti i luoghi e i programmi verranno eliminati in modo permanente.',
+ 'dashboard.editTrip': 'Modifica Viaggio',
+ 'dashboard.createTrip': 'Crea Nuovo Viaggio',
+ 'dashboard.tripTitle': 'Titolo',
+ 'dashboard.tripTitlePlaceholder': 'es. Estate in Giappone',
+ 'dashboard.tripDescription': 'Descrizione',
+ 'dashboard.tripDescriptionPlaceholder': 'Di cosa tratta questo viaggio?',
+ 'dashboard.startDate': 'Data di inizio',
+ 'dashboard.endDate': 'Data di fine',
+ 'dashboard.noDateHint': 'Nessuna data impostata — verranno creati 7 giorni predefiniti. Puoi cambiarlo in qualsiasi momento.',
+ 'dashboard.coverImage': 'Immagine di copertina',
+ 'dashboard.addCoverImage': 'Aggiungi immagine di copertina (o trascinala qui)',
+ 'dashboard.addMembers': 'Compagni di viaggio',
+ 'dashboard.addMember': 'Aggiungi membro',
+ 'dashboard.coverSaved': 'Immagine di copertina salvata',
+ 'dashboard.coverUploadError': 'Impossibile caricare',
+ 'dashboard.coverRemoveError': 'Impossibile rimuovere',
+ 'dashboard.titleRequired': 'Il titolo è obbligatorio',
+ 'dashboard.endDateError': 'La data di fine deve essere successiva alla data di inizio',
+
+ // Settings
+ 'settings.title': 'Impostazioni',
+ 'settings.subtitle': 'Configura le tue impostazioni personali',
+ 'settings.map': 'Mappa',
+ 'settings.mapTemplate': 'Modello Mappa',
+ 'settings.mapTemplatePlaceholder.select': 'Seleziona modello...',
+ 'settings.mapDefaultHint': 'Lascia vuoto per OpenStreetMap (predefinito)',
+ 'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
+ 'settings.mapHint': 'Modello URL per i tile della mappa',
+ 'settings.latitude': 'Latitudine',
+ 'settings.longitude': 'Longitudine',
+ 'settings.saveMap': 'Salva Mappa',
+ 'settings.apiKeys': 'Chiavi API',
+ 'settings.mapsKey': 'Chiave API Google Maps',
+ 'settings.mapsKeyHint': 'Per la ricerca dei luoghi. Richiede Places API (New). Ottienila su console.cloud.google.com',
+ 'settings.weatherKey': 'Chiave API OpenWeatherMap',
+ 'settings.weatherKeyHint': 'Per i dati meteo. Gratuita su openweathermap.org/api',
+ 'settings.keyPlaceholder': 'Inserisci la chiave...',
+ 'settings.configured': 'Configurata',
+ 'settings.saveKeys': 'Salva Chiavi',
+ 'settings.display': 'Visualizzazione',
+ 'settings.colorMode': 'Modalità Colore',
+ 'settings.light': 'Chiara',
+ 'settings.dark': 'Scura',
+ 'settings.auto': 'Automatica',
+ 'settings.language': 'Lingua',
+ 'settings.temperature': 'Unità di Temperatura',
+ 'settings.timeFormat': 'Formato Ora',
+ 'settings.routeCalculation': 'Calcolo Percorso',
+ 'settings.blurBookingCodes': 'Nascondi codici di prenotazione',
+ 'settings.notifications': 'Notifiche',
+ 'settings.notifyTripInvite': 'Inviti di viaggio',
+ 'settings.notifyBookingChange': 'Modifiche alle prenotazioni',
+ 'settings.notifyTripReminder': 'Promemoria di viaggio',
+ 'settings.notifyVacayInvite': 'Inviti fusione Vacay',
+ 'settings.notifyPhotosShared': 'Foto condivise (Immich)',
+ 'settings.notifyCollabMessage': 'Messaggi chat (Collab)',
+ 'settings.notifyPackingTagged': 'Lista valigia: assegnazioni',
+ 'settings.notifyWebhook': 'Notifiche webhook',
+ 'settings.on': 'On',
+ 'settings.off': 'Off',
+ 'settings.mcp.title': 'Configurazione MCP',
+ 'settings.mcp.endpoint': 'Endpoint MCP',
+ 'settings.mcp.clientConfig': 'Configurazione client',
+ 'settings.mcp.clientConfigHint': 'Sostituisci con un token API dalla lista sottostante. Il percorso di npx potrebbe dover essere adattato per il tuo sistema (es. C:\\PROGRA~1\\nodejs\\npx.cmd su Windows).',
+ 'settings.mcp.copy': 'Copia',
+ 'settings.mcp.copied': 'Copiato!',
+ 'settings.mcp.apiTokens': 'Token API',
+ 'settings.mcp.createToken': 'Crea nuovo token',
+ 'settings.mcp.noTokens': 'Nessun token ancora. Creane uno per connettere i client MCP.',
+ 'settings.mcp.tokenCreatedAt': 'Creato',
+ 'settings.mcp.tokenUsedAt': 'Utilizzato',
+ 'settings.mcp.deleteTokenTitle': 'Elimina token',
+ 'settings.mcp.deleteTokenMessage': 'Questo token smetterà di funzionare immediatamente. Qualsiasi client MCP che lo utilizza perderà l\'accesso.',
+ 'settings.mcp.modal.createTitle': 'Crea token API',
+ 'settings.mcp.modal.tokenName': 'Nome del token',
+ 'settings.mcp.modal.tokenNamePlaceholder': 'es. Claude Desktop, Laptop di lavoro',
+ 'settings.mcp.modal.creating': 'Creazione…',
+ 'settings.mcp.modal.create': 'Crea token',
+ 'settings.mcp.modal.createdTitle': 'Token creato',
+ 'settings.mcp.modal.createdWarning': 'Questo token verrà mostrato solo una volta. Copialo e salvalo ora — non può essere recuperato.',
+ 'settings.mcp.modal.done': 'Fatto',
+ 'settings.mcp.toast.created': 'Token creato',
+ 'settings.mcp.toast.createError': 'Impossibile creare il token',
+ 'settings.mcp.toast.deleted': 'Token eliminato',
+ 'settings.mcp.toast.deleteError': 'Impossibile eliminare il token',
+ 'settings.account': 'Account',
+ 'settings.username': 'Username',
+ 'settings.email': 'Email',
+ 'settings.role': 'Ruolo',
+ 'settings.roleAdmin': 'Amministratore',
+ 'settings.oidcLinked': 'Collegato con',
+ 'settings.changePassword': 'Cambia Password',
+ 'settings.currentPassword': 'Password attuale',
+ 'settings.currentPasswordRequired': 'La password attuale è obbligatoria',
+ 'settings.newPassword': 'Nuova password',
+ 'settings.confirmPassword': 'Conferma nuova password',
+ 'settings.updatePassword': 'Aggiorna password',
+ 'settings.passwordRequired': 'Inserisci la password attuale e quella nuova',
+ 'settings.passwordTooShort': 'La password deve contenere almeno 8 caratteri',
+ 'settings.passwordMismatch': 'Le password non corrispondono',
+ 'settings.passwordWeak': 'La password deve contenere lettere maiuscole, minuscole e un numero',
+ 'settings.passwordChanged': 'Password cambiata con successo',
+ 'settings.deleteAccount': 'Elimina account',
+ 'settings.deleteAccountTitle': 'Eliminare il tuo account?',
+ 'settings.deleteAccountWarning': 'Il tuo account e tutti i tuoi viaggi, luoghi e file verranno eliminati in modo permanente. Questa azione non può essere annullata.',
+ 'settings.deleteAccountConfirm': 'Elimina in modo permanente',
+ 'settings.deleteBlockedTitle': 'Eliminazione impossibile',
+ 'settings.deleteBlockedMessage': 'Sei l\'unico amministratore. Promuovi un altro utente ad amministratore prima di eliminare il tuo account.',
+ 'settings.roleUser': 'Utente',
+ 'settings.saveProfile': 'Salva Profillo',
+ 'settings.toast.mapSaved': 'Impostazioni mappa salvate',
+ 'settings.toast.keysSaved': 'Chiavi API salvate',
+ 'settings.toast.displaySaved': 'Impostazioni di visualizzazione salvate',
+ 'settings.toast.profileSaved': 'Profilo salvato',
+ 'settings.uploadAvatar': 'Carica Immagine del Profilo',
+ 'settings.removeAvatar': 'Rimuovi Immagine del Profilo',
+ 'settings.avatarUploaded': 'Immagine del profilo aggiornata',
+ 'settings.avatarRemoved': 'Immagine del profilo rimossa',
+ 'settings.avatarError': 'Impossibile caricare',
+ 'settings.mfa.title': 'Autenticazione a due fattori (2FA)',
+ 'settings.mfa.description': 'Aggiunge un secondo passaggio quando accedi con email e password. Usa un\'app authenticator (Google Authenticator, Authy, ecc.).',
+ 'settings.mfa.requiredByPolicy': 'L\'amministratore richiede l\'autenticazione a due fattori. Configura un\'app authenticator qui sotto prima di continuare.',
+ 'settings.mfa.backupTitle': 'Codici di backup',
+ 'settings.mfa.backupDescription': 'Usa questi codici monouso se perdi l\'accesso alla tua app authenticator.',
+ 'settings.mfa.backupWarning': 'Salvali adesso. Ogni codice può essere usato una sola volta.',
+ 'settings.mfa.backupCopy': 'Copia codici',
+ 'settings.mfa.backupDownload': 'Scarica TXT',
+ 'settings.mfa.backupPrint': 'Stampa / PDF',
+ 'settings.mfa.backupCopied': 'Codici di backup copiati',
+ 'settings.mfa.enabled': 'La 2FA è abilitata sul tuo account.',
+ 'settings.mfa.disabled': 'La 2FA non è abilitata.',
+ 'settings.mfa.setup': 'Configura authenticator',
+ 'settings.mfa.scanQr': 'Scansiona questo codice QR con la tua app, o inserisci il segreto manualmente.',
+ 'settings.mfa.secretLabel': 'Chiave segreta (inserimento manuale)',
+ 'settings.mfa.codePlaceholder': 'Codice a 6 cifre',
+ 'settings.mfa.enable': 'Abilita 2FA',
+ 'settings.mfa.cancelSetup': 'Annulla',
+ 'settings.mfa.disableTitle': 'Disabilita 2FA',
+ 'settings.mfa.disableHint': 'Inserisci la password del tuo account e un codice attuale dal tuo authenticator.',
+ 'settings.mfa.disable': 'Disabilita 2FA',
+ 'settings.mfa.toastEnabled': 'Autenticazione a due fattori abilitata',
+ 'settings.mfa.toastDisabled': 'Autenticazione a due fattori disabilitata',
+ 'settings.mfa.demoBlocked': 'Non disponibile in modalità demo',
+ 'admin.smtp.title': 'Email e notifiche',
+ 'admin.smtp.hint': 'Configurazione SMTP per le notifiche via email. Opzionale: URL webhook per Discord, Slack, ecc.',
+ 'admin.smtp.testButton': 'Invia email di prova',
+ 'admin.smtp.testSuccess': 'Email di prova inviata con successo',
+ 'admin.smtp.testFailed': 'Invio email di prova fallito',
+ 'dayplan.icsTooltip': 'Esporta calendario (ICS)',
+ 'share.linkTitle': 'Link pubblico',
+ 'share.linkHint': 'Crea un link che chiunque può usare per visualizzare questo viaggio senza accedere. Solo lettura — nessuna modifica possibile.',
+ 'share.createLink': 'Crea link',
+ 'share.deleteLink': 'Elimina link',
+ 'share.createError': 'Impossibile creare il link',
+ 'common.copy': 'Copia',
+ 'common.copied': 'Copiato',
+ 'share.permMap': 'Mappa e programma',
+ 'share.permBookings': 'Prenotazioni',
+ 'share.permPacking': 'Valigia',
+ 'shared.expired': 'Link scaduto o non valido',
+ 'shared.expiredHint': 'Questo link di viaggio condiviso non è più attivo.',
+ 'shared.readOnly': 'Vista in sola lettura',
+ 'shared.tabPlan': 'Programma',
+ 'shared.tabBookings': 'Prenotazioni',
+ 'shared.tabPacking': 'Valigia',
+ 'shared.tabBudget': 'Budget',
+ 'shared.tabChat': 'Chat',
+ 'shared.days': 'giorni',
+ 'shared.places': 'luoghi',
+ 'shared.other': 'Altro',
+ 'shared.totalBudget': 'Budget totale',
+ 'shared.messages': 'messaggi',
+ 'shared.sharedVia': 'Condiviso tramite',
+ 'shared.confirmed': 'Confermato',
+ 'shared.pending': 'In attesa',
+ 'share.permBudget': 'Budget',
+ 'share.permCollab': 'Chat',
+
+ // Login
+ 'login.error': 'Accesso fallito. Controlla le tue credenziali.',
+ 'login.tagline': 'I tuoi viaggi.\nIl tuo programma.',
+ 'login.description': 'Programma viaggi in collaborazione con mappe interattive, budget e sincronizzazione in tempo reale.',
+ 'login.features.maps': 'Mappe Interattive',
+ 'login.features.mapsDesc': 'Google Places, percorsi e clustering',
+ 'login.features.realtime': 'Sincronizzazione in tempo reale',
+ 'login.features.realtimeDesc': 'Programmate insieme tramite WebSocket',
+ 'login.features.budget': 'Tracciamento Budget',
+ 'login.features.budgetDesc': 'Categorie, grafici e costi per persona',
+ 'login.features.collab': 'Collaborazione',
+ 'login.features.collabDesc': 'Multi-utente con viaggi condivisi',
+ 'login.features.packing': 'Lista Valigia',
+ 'login.features.packingDesc': 'Categorie, progressi e suggerimenti',
+ 'login.features.bookings': 'Prenotazioni',
+ 'login.features.bookingsDesc': 'Voli, alloggi, ristoranti e altro',
+ 'login.features.files': 'Documenti',
+ 'login.features.filesDesc': 'Carica e gestisci i documenti',
+ 'login.features.routes': 'Percorsi Intelligenti',
+ 'login.features.routesDesc': 'Ottimizzazione automatica ed esportazione su Google Maps',
+ 'login.selfHosted': 'Self-hosted · Open Source · Your data stays yours',
+ 'login.title': 'Accedi',
+ 'login.subtitle': 'Bentornato',
+ 'login.signingIn': 'Accesso in corso…',
+ 'login.signIn': 'Accedi',
+ 'login.createAdmin': 'Crea Account Amministratore',
+ 'login.createAdminHint': 'Imposta il primo account amministratore per TREK.',
+ 'login.createAccount': 'Crea Account',
+ 'login.createAccountHint': 'Registra un nuovo account.',
+ 'login.creating': 'Creazione in corso…',
+ 'login.noAccount': "Non hai un account?",
+ 'login.hasAccount': 'Hai già un account?',
+ 'login.register': 'Registrati',
+ 'login.emailPlaceholder': 'tua@email.com',
+ 'login.username': 'Username',
+ 'login.oidc.registrationDisabled': 'La registrazione è disabilitata. Contatta il tuo amministratore.',
+ 'login.oidc.noEmail': 'Nessuna email ricevuta dal provider.',
+ 'login.oidc.tokenFailed': 'Autenticazione fallita.',
+ 'login.oidc.invalidState': 'Sessione non valida. Riprova.',
+ 'login.demoFailed': 'Accesso demo fallito',
+ 'login.oidcSignIn': 'Accedi con {name}',
+ 'login.oidcOnly': 'L\'autenticazione tramite password è disabilitata. Accedi utilizzando il tuo provider SSO.',
+ 'login.demoHint': 'Prova la demo — nessuna registrazione necessaria',
+ 'login.mfaTitle': 'Autenticazione a due fattori',
+ 'login.mfaSubtitle': 'Inserisci il codice a 6 cifre dalla tua app authenticator.',
+ 'login.mfaCodeLabel': 'Codice di verifica',
+ 'login.mfaCodeRequired': 'Inserisci il codice dalla tua app authenticator.',
+ 'login.mfaHint': 'Apri Google Authenticator, Authy o un\'altra app TOTP.',
+ 'login.mfaBack': '← Torna all\'accesso',
+ 'login.mfaVerify': 'Verifica',
+
+ // Register
+ 'register.passwordMismatch': 'Le password non corrispondono',
+ 'register.passwordTooShort': 'La password deve contenere almeno 6 caratteri',
+ 'register.failed': 'Registrazione fallita',
+ 'register.getStarted': 'Inizia',
+ 'register.subtitle': 'Crea un account e inizia a programmare i viaggi dei tuoi sogni.',
+ 'register.feature1': 'Piani di viaggio illimitati',
+ 'register.feature2': 'Vista mappa interattiva',
+ 'register.feature3': 'Gestisci luoghi e categorie',
+ 'register.feature4': 'Traccia le prenotazioni',
+ 'register.feature5': 'Crea liste per la valigia',
+ 'register.feature6': 'Archivia foto e file',
+ 'register.createAccount': 'Crea Account',
+ 'register.startPlanning': 'Inizia a programmare il tuo viaggio',
+ 'register.minChars': 'Min. 6 caratteri',
+ 'register.confirmPassword': 'Conferma Password',
+ 'register.repeatPassword': 'Ripeti password',
+ 'register.registering': 'Registrazione in corso...',
+ 'register.register': 'Registrati',
+ 'register.hasAccount': 'Hai già un account?',
+ 'register.signIn': 'Accedi',
+
+ // Admin
+ 'admin.title': 'Amministrazione',
+ 'admin.subtitle': 'Gestione utenti e impostazioni di sistema',
+ 'admin.tabs.users': 'Utenti',
+ 'admin.tabs.categories': 'Categorie',
+ 'admin.tabs.backup': 'Backup',
+ 'admin.stats.users': 'Utenti',
+ 'admin.stats.trips': 'Viaggi',
+ 'admin.stats.places': 'Luoghi',
+ 'admin.stats.photos': 'Foto',
+ 'admin.stats.files': 'File',
+ 'admin.table.user': 'Utente',
+ 'admin.table.email': 'Email',
+ 'admin.table.role': 'Ruolo',
+ 'admin.table.created': 'Creato',
+ 'admin.table.lastLogin': 'Ultimo Accesso',
+ 'admin.table.actions': 'Azioni',
+ 'admin.you': '(Tu)',
+ 'admin.editUser': 'Modifica Utente',
+ 'admin.newPassword': 'Nuova Password',
+ 'admin.newPasswordHint': 'Lascia vuoto per mantenere la password attuale',
+ 'admin.deleteUser': 'Eliminare l\'utente "{name}"? Tutti i viaggi verranno eliminati in modo permanente.',
+ 'admin.deleteUserTitle': 'Elimina utente',
+ 'admin.newPasswordPlaceholder': 'Inserisci nuova password…',
+ 'admin.toast.loadError': 'Impossibile caricare i dati di amministrazione',
+ 'admin.toast.userUpdated': 'Utente aggiornato',
+ 'admin.toast.updateError': 'Impossibile aggiornare',
+ 'admin.toast.userDeleted': 'Utente eliminato',
+ 'admin.toast.deleteError': 'Impossibile eliminare',
+ 'admin.toast.cannotDeleteSelf': 'Impossibile eliminare il proprio account',
+ 'admin.toast.userCreated': 'Utente creato',
+ 'admin.toast.createError': 'Impossibile creare l\'utente',
+ 'admin.toast.fieldsRequired': 'Username, email e password sono obbligatori',
+ 'admin.createUser': 'Crea Utente',
+ 'admin.invite.title': 'Link di Invito',
+ 'admin.invite.subtitle': 'Crea link di registrazione monouso',
+ 'admin.invite.create': 'Crea Link',
+ 'admin.invite.createAndCopy': 'Crea & Copia',
+ 'admin.invite.empty': 'Nessun link di invito ancora creato',
+ 'admin.invite.maxUses': 'Usi Max.',
+ 'admin.invite.expiry': 'Scade tra',
+ 'admin.invite.uses': 'usato',
+ 'admin.invite.expiresAt': 'scade',
+ 'admin.invite.createdBy': 'da',
+ 'admin.invite.active': 'Attivo',
+ 'admin.invite.expired': 'Scaduto',
+ 'admin.invite.usedUp': 'Esaurito',
+ 'admin.invite.copied': 'Link di invito copiato negli appunti',
+ 'admin.invite.copyLink': 'Copia link',
+ 'admin.invite.deleted': 'Link di invito eliminato',
+ 'admin.invite.createError': 'Impossibile creare il link di invito',
+ 'admin.invite.deleteError': 'Impossibile eliminare il link di invito',
+ 'admin.tabs.settings': 'Impostazioni',
+ 'admin.allowRegistration': 'Consenti Registrazione',
+ 'admin.allowRegistrationHint': 'I nuovi utenti possono registrarsi autonomamente',
+ 'admin.requireMfa': 'Richiedi autenticazione a due fattori (2FA)',
+ 'admin.requireMfaHint': 'Gli utenti senza 2FA devono completare la configurazione in Impostazioni prima di usare l\'app.',
+ 'admin.apiKeys': 'Chiavi API',
+ 'admin.apiKeysHint': 'Opzionale. Abilita dati estesi per i luoghi come foto e meteo.',
+ 'admin.mapsKey': 'Chiave API Google Maps',
+ 'admin.mapsKeyHint': 'Richiesta per la ricerca dei luoghi. Ottienila su console.cloud.google.com',
+ 'admin.mapsKeyHintLong': 'Senza una chiave API, OpenStreetMap viene utilizzato per la ricerca dei luoghi. Con una chiave API di Google, è possibile caricare anche foto, valutazioni e orari di apertura. Ottienine una su console.cloud.google.com.',
+ 'admin.recommended': 'Consigliato',
+ 'admin.weatherKey': 'Chiave API OpenWeatherMap',
+ 'admin.weatherKeyHint': 'Per i dati meteo. Gratuita su openweathermap.org',
+ 'admin.validateKey': 'Testa',
+ 'admin.keyValid': 'Connessa',
+ 'admin.keyInvalid': 'Non valida',
+ 'admin.keySaved': 'Chiavi API salvate',
+ 'admin.oidcTitle': 'Single Sign-On (OIDC)',
+ 'admin.oidcSubtitle': 'Consenti l\'accesso tramite provider esterni come Google, Apple, Authentik o Keycloak.',
+ 'admin.oidcDisplayName': 'Nome Visualizzato',
+ 'admin.oidcIssuer': 'URL Emittente',
+ 'admin.oidcIssuerHint': 'L\'URL dell\'Emittente OpenID Connect del provider. es. https://accounts.google.com',
+ 'admin.oidcSaved': 'Configurazione OIDC salvata',
+ 'admin.oidcOnlyMode': 'Disabilita autenticazione con password',
+ 'admin.oidcOnlyModeHint': 'Se abilitato, è consentito solo l\'accesso SSO. L\'accesso basato su password e la registrazione sono bloccati.',
+
+ // File Types
+ 'admin.fileTypes': 'Tipi di File Consentiti',
+ 'admin.fileTypesHint': 'Configura quali tipi di file gli utenti possono caricare.',
+ 'admin.fileTypesFormat': 'Estensioni separate da virgola (es. jpg,png,pdf,doc). Usa * per consentire tutti i tipi.',
+ 'admin.fileTypesSaved': 'Impostazioni dei tipi di file salvate',
+ // Packing Templates & Bag Tracking
+ 'admin.bagTracking.title': 'Tracciamento valigia',
+ 'admin.bagTracking.subtitle': 'Abilita il peso e l\'assegnazione della valigia per gli elementi della lista valigia',
+ 'admin.tabs.config': 'Configurazione',
+ 'admin.tabs.templates': 'Modelli lista valigia',
+ 'admin.packingTemplates.title': 'Modelli lista valigia',
+ 'admin.packingTemplates.subtitle': 'Crea liste valigia riutilizzabili per i tuoi viaggi',
+ 'admin.packingTemplates.create': 'Nuovo modello',
+ 'admin.packingTemplates.namePlaceholder': 'Nome modello (es. Vacanza al mare)',
+ 'admin.packingTemplates.empty': 'Ancora nessun modello creato',
+ 'admin.packingTemplates.items': 'elementi',
+ 'admin.packingTemplates.categories': 'categorie',
+ 'admin.packingTemplates.itemName': 'Nome elemento',
+ 'admin.packingTemplates.itemCategory': 'Categoria',
+ 'admin.packingTemplates.categoryName': 'Nome categoria (es. Abbigliamento)',
+ 'admin.packingTemplates.addCategory': 'Aggiungi categoria',
+ 'admin.packingTemplates.created': 'Modello creato',
+ 'admin.packingTemplates.deleted': 'Modello eliminato',
+ 'admin.packingTemplates.loadError': 'Impossibile caricare i modelli',
+ 'admin.packingTemplates.createError': 'Impossibile creare il modello',
+ 'admin.packingTemplates.deleteError': 'Impossibile eliminare il modello',
+ 'admin.packingTemplates.saveError': 'Impossibile salvare',
+
+ // Addons
+ 'admin.tabs.addons': 'Moduli',
+ 'admin.addons.title': 'Moduli',
+ 'admin.addons.subtitle': 'Abilita o disabilita le funzionalità per personalizzare la tua esperienza TREK.',
+ 'admin.addons.catalog.packing.name': 'Lista valigia',
+ 'admin.addons.catalog.packing.description': 'Checklist per preparare la valigia per ogni viaggio',
+ 'admin.addons.catalog.budget.name': 'Budget',
+ 'admin.addons.catalog.budget.description': 'Tieni traccia delle spese e pianifica il budget del tuo viaggio',
+ 'admin.addons.catalog.documents.name': 'Documenti',
+ 'admin.addons.catalog.documents.description': 'Archivia e gestisci i documenti di viaggio',
+ 'admin.addons.catalog.vacay.name': 'Ferie',
+ 'admin.addons.catalog.vacay.description': 'Pianificatore personale delle ferie con vista calendario',
+ 'admin.addons.catalog.atlas.name': 'Atlante',
+ 'admin.addons.catalog.atlas.description': 'Mappa del mondo con paesi visitati e statistiche di viaggio',
+ 'admin.addons.catalog.collab.name': 'Collaborazione',
+ 'admin.addons.catalog.collab.description': 'Note, sondaggi e chat in tempo reale per la pianificazione del viaggio',
+ 'admin.addons.catalog.memories.name': 'Foto (Immich)',
+ 'admin.addons.catalog.memories.description': 'Condividi le foto del viaggio tramite la tua istanza Immich',
+ 'admin.addons.catalog.mcp.name': 'MCP',
+ 'admin.addons.catalog.mcp.description': 'Model Context Protocol per l\'integrazione di assistenti AI',
+ 'admin.addons.subtitleBefore': 'Abilita o disabilita le funzionalità per personalizzare la tua ',
+ 'admin.addons.subtitleAfter': ' esperienza.',
+ 'admin.addons.enabled': 'Abilitato',
+ 'admin.addons.disabled': 'Disabilitato',
+ 'admin.addons.type.trip': 'Viaggio',
+ 'admin.addons.type.global': 'Globale',
+ 'admin.addons.type.integration': 'Integrazione',
+ 'admin.addons.tripHint': 'Disponibile come scheda all\'interno di ciascun viaggio',
+ 'admin.addons.globalHint': 'Disponibile come sezione autonoma nella navigazione principale',
+ 'admin.addons.integrationHint': 'Servizi backend e integrazioni API senza pagina dedicata',
+ 'admin.addons.toast.updated': 'Modulo aggiornato',
+ 'admin.addons.toast.error': 'Impossibile aggiornare il modulo',
+ 'admin.addons.noAddons': 'Nessun modulo disponibile',
+
+ // Weather info
+ 'admin.weather.title': 'Dati meteo',
+ 'admin.weather.badge': 'Dal 24 marzo 2026',
+ 'admin.weather.description': 'TREK utilizza Open-Meteo come fonte dei dati meteo. Open-Meteo è un servizio meteo gratuito e open-source — non è richiesta alcuna chiave API.',
+ 'admin.weather.forecast': 'Previsioni a 16 giorni',
+ 'admin.weather.forecastDesc': 'In precedenza 5 giorni (OpenWeatherMap)',
+ 'admin.weather.climate': 'Dati climatici storici',
+ 'admin.weather.climateDesc': 'Medie degli ultimi 85 anni per i giorni oltre le previsioni a 16 giorni',
+ 'admin.weather.requests': '10.000 richieste / giorno',
+ 'admin.weather.requestsDesc': 'Gratis, nessuna chiave API richiesta',
+ 'admin.weather.locationHint': 'Il meteo si basa sul primo luogo con coordinate di ogni giorno. Se a un giorno non è assegnato alcun luogo, viene utilizzato come riferimento un qualsiasi luogo dell\'elenco.',
+
+ 'admin.tabs.audit': 'Log di audit',
+
+ 'admin.audit.subtitle': 'Eventi sensibili di sicurezza e amministrazione (backup, utenti, 2FA, impostazioni).',
+ 'admin.audit.empty': 'Nessuna voce di audit.',
+ 'admin.audit.refresh': 'Aggiorna',
+ 'admin.audit.loadMore': 'Carica altro',
+ 'admin.audit.showing': '{count} caricati · {total} totali',
+ 'admin.audit.col.time': 'Ora',
+ 'admin.audit.col.user': 'Utente',
+ 'admin.audit.col.action': 'Azione',
+ 'admin.audit.col.resource': 'Risorsa',
+ 'admin.audit.col.ip': 'IP',
+ 'admin.audit.col.details': 'Dettagli',
+
+ // MCP Tokens
+ 'admin.tabs.mcpTokens': 'Token MCP',
+ 'admin.mcpTokens.title': 'Token MCP',
+ 'admin.mcpTokens.subtitle': 'Gestisci i token API di tutti gli utenti',
+ 'admin.mcpTokens.owner': 'Proprietario',
+ 'admin.mcpTokens.tokenName': 'Nome token',
+ 'admin.mcpTokens.created': 'Creato',
+ 'admin.mcpTokens.lastUsed': 'Ultimo utilizzo',
+ 'admin.mcpTokens.never': 'Mai',
+ 'admin.mcpTokens.empty': 'Non sono ancora stati creati token MCP',
+ 'admin.mcpTokens.deleteTitle': 'Elimina token',
+ 'admin.mcpTokens.deleteMessage': 'Questo token verrà revocato immediatamente. L\'utente perderà l\'accesso MCP tramite questo token.',
+ 'admin.mcpTokens.deleteSuccess': 'Token eliminato',
+ 'admin.mcpTokens.deleteError': 'Impossibile eliminare il token',
+ 'admin.mcpTokens.loadError': 'Impossibile caricare i token',
+
+ // GitHub
+ 'admin.tabs.github': 'GitHub',
+ 'admin.github.title': 'Cronologia rilasci',
+ 'admin.github.subtitle': 'Ultimi aggiornamenti da {repo}',
+ 'admin.github.latest': 'Ultimo',
+ 'admin.github.prerelease': 'Pre-release',
+ 'admin.github.showDetails': 'Mostra dettagli',
+ 'admin.github.hideDetails': 'Nascondi dettagli',
+ 'admin.github.loadMore': 'Carica altro',
+ 'admin.github.loading': 'Caricamento...',
+ 'admin.github.error': 'Impossibile caricare i rilasci',
+ 'admin.github.by': 'da',
+ 'admin.github.support': 'Mi aiuta a continuare a sviluppare TREK',
+
+ 'admin.update.available': 'Aggiornamento disponibile',
+ 'admin.update.text': 'TREK {version} è disponibile. Stai eseguendo {current}.',
+ 'admin.update.button': 'Vedi su GitHub',
+ 'admin.update.install': 'Installa aggiornamento',
+ 'admin.update.confirmTitle': 'Installare l\'aggiornamento?',
+ 'admin.update.confirmText': 'TREK verrà aggiornato da {current} a {version}. Il server si riavvierà automaticamente in seguito.',
+ 'admin.update.dataInfo': 'Tutti i tuoi dati (viaggi, utenti, chiavi API, caricamenti, Ferie, Atlante, budget) saranno preservati.',
+ 'admin.update.warning': 'L\'app sarà temporaneamente non disponibile durante il riavvio.',
+ 'admin.update.confirm': 'Aggiorna ora',
+ 'admin.update.installing': 'Aggiornamento in corso…',
+ 'admin.update.success': 'Aggiornamento installato! Il server si sta riavviando…',
+ 'admin.update.failed': 'Aggiornamento non riuscito',
+ 'admin.update.backupHint': 'Ti consigliamo di creare un backup prima di aggiornare.',
+ 'admin.update.backupLink': 'Vai a Backup',
+ 'admin.update.howTo': 'Come aggiornare',
+ 'admin.update.dockerText': 'La tua istanza TREK è in esecuzione in Docker. Per aggiornare alla versione {version}, esegui i seguenti comandi sul tuo server:',
+ 'admin.update.reloadHint': 'Ricarica la pagina tra qualche secondo.',
+
+ // Vacay addon
+ 'vacay.subtitle': 'Pianifica e gestisci i giorni di ferie',
+ 'vacay.settings': 'Impostazioni',
+ 'vacay.year': 'Anno',
+ 'vacay.addYear': 'Aggiungi anno',
+ 'vacay.removeYear': 'Rimuovi anno',
+ 'vacay.removeYearConfirm': 'Rimuovere {year}?',
+ 'vacay.removeYearHint': 'Tutte le voci delle ferie e le ferie aziendali di questo anno verranno eliminate in modo permanente.',
+ 'vacay.remove': 'Rimuovi',
+ 'vacay.persons': 'Persone',
+ 'vacay.noPersons': 'Nessuna persona aggiunta',
+ 'vacay.addPerson': 'Aggiungi persona',
+ 'vacay.editPerson': 'Modifica persona',
+ 'vacay.removePerson': 'Rimuovi persona',
+ 'vacay.removePersonConfirm': 'Rimuovere {name}?',
+ 'vacay.removePersonHint': 'Tutte le voci delle ferie per questa persona verranno eliminate in modo permanente.',
+ 'vacay.personName': 'Nome',
+ 'vacay.personNamePlaceholder': 'Inserisci nome',
+ 'vacay.color': 'Colore',
+ 'vacay.add': 'Aggiungi',
+ 'vacay.legend': 'Legenda',
+ 'vacay.publicHoliday': 'Festività pubblica',
+ 'vacay.companyHoliday': 'Ferie aziendali',
+ 'vacay.weekend': 'Weekend',
+ 'vacay.modeVacation': 'Ferie',
+ 'vacay.modeCompany': 'Ferie aziendali',
+ 'vacay.entitlement': 'Disponibilità',
+ 'vacay.entitlementDays': 'Giorni',
+ 'vacay.used': 'Usati',
+ 'vacay.remaining': 'Rimanenti',
+ 'vacay.carriedOver': 'dal {year}',
+ 'vacay.blockWeekends': 'Blocca weekend',
+ 'vacay.blockWeekendsHint': 'Impedisci le voci ferie nei giorni del weekend',
+ 'vacay.weekendDays': 'Giorni del weekend',
+ 'vacay.mon': 'Lun',
+ 'vacay.tue': 'Mar',
+ 'vacay.wed': 'Mer',
+ 'vacay.thu': 'Gio',
+ 'vacay.fri': 'Ven',
+ 'vacay.sat': 'Sab',
+ 'vacay.sun': 'Dom',
+ 'vacay.publicHolidays': 'Festività pubbliche',
+ 'vacay.publicHolidaysHint': 'Segna le festività pubbliche nel calendario',
+ 'vacay.selectCountry': 'Seleziona paese',
+ 'vacay.selectRegion': 'Seleziona regione (opzionale)',
+ 'vacay.addCalendar': 'Aggiungi calendario',
+ 'vacay.calendarLabel': 'Etichetta (opzionale)',
+ 'vacay.calendarColor': 'Colore',
+ 'vacay.noCalendars': 'Ancora nessun calendario delle festività aggiunto',
+ 'vacay.companyHolidays': 'Ferie aziendali',
+ 'vacay.companyHolidaysHint': 'Consenti di segnare giorni di ferie aziendali',
+ 'vacay.companyHolidaysNoDeduct': 'Le ferie aziendali non vengono conteggiate nei giorni di ferie.',
+ 'vacay.carryOver': 'Riporto',
+ 'vacay.carryOverHint': 'Riporta automaticamente i giorni di ferie rimanenti all\'anno successivo',
+ 'vacay.sharing': 'Condivisione',
+ 'vacay.sharingHint': 'Condividi il tuo piano ferie con altri utenti TREK',
+ 'vacay.owner': 'Proprietario',
+ 'vacay.shareEmailPlaceholder': 'Email dell\'utente TREK',
+ 'vacay.shareSuccess': 'Piano condiviso con successo',
+ 'vacay.shareError': 'Impossibile condividere il piano',
+ 'vacay.dissolve': 'Sciogli unione',
+ 'vacay.dissolveHint': 'Separa di nuovo i calendari. Le tue voci verranno mantenute.',
+ 'vacay.dissolveAction': 'Sciogli',
+ 'vacay.dissolved': 'Calendario separato',
+ 'vacay.fusedWith': 'Unito con',
+ 'vacay.you': 'tu',
+ 'vacay.noData': 'Nessun dato',
+ 'vacay.changeColor': 'Cambia colore',
+ 'vacay.inviteUser': 'Invita utente',
+ 'vacay.inviteHint': 'Invita un altro utente TREK a condividere un calendario ferie combinato.',
+ 'vacay.selectUser': 'Seleziona utente',
+ 'vacay.sendInvite': 'Invia invito',
+ 'vacay.inviteSent': 'Invito inviato',
+ 'vacay.inviteError': 'Impossibile inviare l\'invito',
+ 'vacay.pending': 'in attesa',
+ 'vacay.noUsersAvailable': 'Nessun utente disponibile',
+ 'vacay.accept': 'Accetta',
+ 'vacay.decline': 'Rifiuta',
+ 'vacay.acceptFusion': 'Accetta e unisci',
+ 'vacay.inviteTitle': 'Richiesta di unione',
+ 'vacay.inviteWantsToFuse': 'vuole condividere con te un calendario ferie.',
+ 'vacay.fuseInfo1': 'Entrambi vedrete tutte le voci ferie in un unico calendario condiviso.',
+ 'vacay.fuseInfo2': 'Entrambe le parti possono creare e modificare le voci reciproche.',
+ 'vacay.fuseInfo3': 'Entrambe le parti possono eliminare le voci e modificare le disponibilità ferie.',
+ 'vacay.fuseInfo4': 'Le impostazioni come festività pubbliche e ferie aziendali sono condivise.',
+ 'vacay.fuseInfo5': 'L\'unione può essere sciolta in qualsiasi momento da una delle due parti. Le tue voci verranno preservate.',
+ 'nav.myTrips': 'I miei viaggi',
+
+ // Atlas addon
+ 'atlas.subtitle': 'La tua impronta di viaggio nel mondo',
+ 'atlas.countries': 'Paesi',
+ 'atlas.trips': 'Viaggi',
+ 'atlas.places': 'Luoghi',
+ 'atlas.unmark': 'Rimuovi',
+ 'atlas.confirmMark': 'Segnare questo paese come visitato?',
+ 'atlas.confirmUnmark': 'Rimuovere questo paese dalla tua lista dei visitati?',
+ 'atlas.markVisited': 'Segna come visitato',
+ 'atlas.markVisitedHint': 'Aggiungi questo paese alla tua lista dei visitati',
+ 'atlas.addToBucket': 'Aggiungi alla lista desideri',
+ 'atlas.addPoi': 'Aggiungi luogo',
+ 'atlas.bucketNamePlaceholder': 'Nome (paese, città, luogo...)',
+ 'atlas.month': 'Mese',
+ 'atlas.addToBucketHint': 'Salvalo come luogo che vuoi visitare',
+ 'atlas.bucketWhen': 'Quando pensi di visitarlo?',
+ 'atlas.statsTab': 'Statistiche',
+ 'atlas.bucketTab': 'Lista desideri',
+ 'atlas.addBucket': 'Aggiungi alla lista desideri',
+ 'atlas.bucketNamePlaceholder': 'Luogo o destinazione...',
+ 'atlas.bucketNotesPlaceholder': 'Note (opzionale)',
+ 'atlas.bucketEmpty': 'La tua lista desideri è vuota',
+ 'atlas.bucketEmptyHint': 'Aggiungi luoghi che sogni di visitare',
+ 'atlas.days': 'Giorni',
+ 'atlas.visitedCountries': 'Paesi visitati',
+ 'atlas.cities': 'Città',
+ 'atlas.noData': 'Ancora nessun dato di viaggio',
+ 'atlas.noDataHint': 'Crea un viaggio e aggiungi luoghi per vedere la tua mappa del mondo',
+ 'atlas.lastTrip': 'Ultimo viaggio',
+ 'atlas.nextTrip': 'Prossimo viaggio',
+ 'atlas.daysLeft': 'giorni rimasti',
+ 'atlas.streak': 'Serie',
+ 'atlas.year': 'anno',
+ 'atlas.years': 'anni',
+ 'atlas.yearInRow': 'anno consecutivo',
+ 'atlas.yearsInRow': 'anni consecutivi',
+ 'atlas.tripIn': 'viaggio in',
+ 'atlas.tripsIn': 'viaggi in',
+ 'atlas.since': 'dal',
+ 'atlas.europe': 'Europa',
+ 'atlas.asia': 'Asia',
+ 'atlas.northAmerica': 'Nord America',
+ 'atlas.southAmerica': 'Sud America',
+ 'atlas.africa': 'Africa',
+ 'atlas.oceania': 'Oceania',
+ 'atlas.other': 'Altro',
+ 'atlas.firstVisit': 'Primo viaggio',
+ 'atlas.lastVisitLabel': 'Ultimo viaggio',
+ 'atlas.tripSingular': 'Viaggio',
+ 'atlas.tripPlural': 'Viaggi',
+ 'atlas.placeVisited': 'Luogo visitato',
+ 'atlas.placesVisited': 'Luoghi visitati',
+
+ // Trip Planner
+ 'trip.tabs.plan': 'Programma',
+ 'trip.tabs.reservations': 'Prenotazioni',
+ 'trip.tabs.reservationsShort': 'Pren.',
+ 'trip.tabs.packing': 'Lista valigia',
+ 'trip.tabs.packingShort': 'Valigia',
+ 'trip.tabs.budget': 'Budget',
+ 'trip.tabs.files': 'File',
+ 'trip.loading': 'Caricamento viaggio...',
+ 'trip.mobilePlan': 'Programma',
+ 'trip.mobilePlaces': 'Luoghi',
+ 'trip.toast.placeUpdated': 'Luogo aggiornato',
+ 'trip.toast.placeAdded': 'Luogo aggiunto',
+ 'trip.toast.placeDeleted': 'Luogo eliminato',
+ 'trip.toast.selectDay': 'Seleziona prima un giorno',
+ 'trip.toast.assignedToDay': 'Luogo assegnato al giorno',
+ 'trip.toast.reorderError': 'Impossibile riordinare',
+ 'trip.toast.reservationUpdated': 'Prenotazione aggiornata',
+ 'trip.toast.reservationAdded': 'Prenotazione aggiunta',
+ 'trip.toast.deleted': 'Eliminato',
+ 'trip.confirm.deletePlace': 'Sei sicuro di voler eliminare questo luogo?',
+
+ // Day Plan Sidebar
+ 'dayplan.emptyDay': 'Nessun luogo programmato per questo giorno',
+ 'dayplan.addNote': 'Aggiungi nota',
+ 'dayplan.editNote': 'Modifica nota',
+ 'dayplan.noteAdd': 'Aggiungi nota',
+ 'dayplan.noteEdit': 'Modifica nota',
+ 'dayplan.noteTitle': 'Nota',
+ 'dayplan.noteSubtitle': 'Nota giornaliera',
+ 'dayplan.totalCost': 'Costo totale',
+ 'dayplan.days': 'Giorni',
+ 'dayplan.dayN': 'Giorno {n}',
+ 'dayplan.calculating': 'Calcolo in corso...',
+ 'dayplan.route': 'Percorso',
+ 'dayplan.optimize': 'Ottimizza',
+ 'dayplan.optimized': 'Percorso ottimizzato',
+ 'dayplan.routeError': 'Impossibile calcolare il percorso',
+ 'dayplan.toast.needTwoPlaces': 'Servono almeno due luoghi per l\'ottimizzazione del percorso',
+ 'dayplan.toast.routeOptimized': 'Percorso ottimizzato',
+ 'dayplan.toast.noGeoPlaces': 'Nessun luogo con coordinate trovato per il calcolo del percorso',
+ 'dayplan.confirmed': 'Confermata',
+ 'dayplan.pendingRes': 'In attesa',
+ 'dayplan.pdf': 'PDF',
+ 'dayplan.pdfTooltip': 'Esporta il programma del giorno come PDF',
+ 'dayplan.pdfError': 'Impossibile esportare il PDF',
+ 'dayplan.cannotReorderTransport': 'Le prenotazioni con un orario fisso non possono essere riordinate',
+ 'dayplan.confirmRemoveTimeTitle': 'Rimuovere l\'orario?',
+ 'dayplan.confirmRemoveTimeBody': 'Questo luogo ha un orario fisso ({time}). Spostarlo rimuoverà l\'orario e consentirà l\'ordinamento libero.',
+ 'dayplan.confirmRemoveTimeAction': 'Rimuovi orario e sposta',
+ 'dayplan.cannotDropOnTimed': 'Gli elementi non possono essere posizionati tra voci con orario fisso',
+ 'dayplan.cannotBreakChronology': 'Ciò interromperebbe l\'ordine cronologico degli elementi e delle prenotazioni pianificati',
+
+ // Places Sidebar
+ 'places.addPlace': 'Aggiungi Luogo/Attività',
+ 'places.importGpx': 'Importa GPX',
+ 'places.gpxImported': '{count} luoghi importati da GPX',
+ 'places.urlResolved': 'Luogo importato dall\'URL',
+ 'places.gpxError': 'Importazione GPX non riuscita',
+ 'places.assignToDay': 'A quale giorno aggiungere?',
+ 'places.all': 'Tutti',
+ 'places.unplanned': 'Non pianificati',
+ 'places.search': 'Cerca luoghi...',
+ 'places.allCategories': 'Tutte le categorie',
+ 'places.categoriesSelected': 'categorie',
+ 'places.clearFilter': 'Cancella filtro',
+ 'places.count': '{count} luoghi',
+ 'places.countSingular': '1 luogo',
+ 'places.allPlanned': 'Tutti i luoghi sono programmati',
+ 'places.noneFound': 'Nessun luogo trovato',
+ 'places.editPlace': 'Modifica luogo',
+ 'places.formName': 'Nome',
+ 'places.formNamePlaceholder': 'es. Torre Eiffel',
+ 'places.formDescription': 'Descrizione',
+ 'places.formDescriptionPlaceholder': 'Breve descrizione...',
+ 'places.formAddress': 'Indirizzo',
+ 'places.formAddressPlaceholder': 'Via, Città, Paese',
+ 'places.formLat': 'Latitudine (es. 48.8566)',
+ 'places.formLng': 'Longitudine (es. 2.3522)',
+ 'places.formCategory': 'Categoria',
+ 'places.noCategory': 'Nessuna categoria',
+ 'places.categoryNamePlaceholder': 'Nome categoria',
+ 'places.formTime': 'Ora',
+ 'places.startTime': 'Inizio',
+ 'places.endTime': 'Fine',
+ 'places.endTimeBeforeStart': 'L\'ora di fine è precedente all\'ora di inizio',
+ 'places.timeCollision': 'Sovrapposizione di orario con:',
+ 'places.formWebsite': 'Sito web',
+ 'places.formNotesPlaceholder': 'Note personali...',
+ 'places.formReservation': 'Prenotazione',
+ 'places.reservationNotesPlaceholder': 'Note della prenotazione, numero di conferma...',
+ 'places.mapsSearchPlaceholder': 'Cerca luoghi...',
+ 'places.mapsSearchError': 'Impossibile cercare i luoghi.',
+ 'places.osmHint': 'Uso della ricerca OpenStreetMap (senza foto, orari di apertura o valutazioni). Aggiungi una chiave API Google nelle impostazioni per i dettagli completi.',
+ 'places.osmActive': 'Ricerca tramite OpenStreetMap (senza foto, valutazioni o orari di apertura). Aggiungi una chiave API Google nelle Impostazioni per dati avanzati.',
+ 'places.categoryCreateError': 'Impossibile creare la categoria',
+ 'places.nameRequired': 'Inserisci un nome',
+ 'places.saveError': 'Impossibile salvare',
+ // Place Inspector
+ 'inspector.opened': 'Aperto',
+ 'inspector.closed': 'Chiuso',
+ 'inspector.openingHours': 'Orari di apertura',
+ 'inspector.showHours': 'Mostra orari di apertura',
+ 'inspector.files': 'File',
+ 'inspector.filesCount': '{count} file',
+ 'inspector.removeFromDay': 'Rimuovi dal giorno',
+ 'inspector.addToDay': 'Aggiungi al giorno',
+ 'inspector.confirmedRes': 'Prenotazione confermata',
+ 'inspector.pendingRes': 'Prenotazione in attesa',
+ 'inspector.google': 'Apri in Google Maps',
+ 'inspector.website': 'Apri sito web',
+ 'inspector.addRes': 'Prenotazione',
+ 'inspector.editRes': 'Modifica prenotazione',
+ 'inspector.participants': 'Partecipanti',
+
+ // Reservations
+ 'reservations.title': 'Prenotazioni',
+ 'reservations.empty': 'Ancora nessuna prenotazione',
+ 'reservations.emptyHint': 'Aggiungi prenotazioni per voli, alloggi e altro',
+ 'reservations.add': 'Aggiungi prenotazione',
+ 'reservations.addManual': 'Prenotazione manuale',
+ 'reservations.placeHint': 'Suggerimento: è meglio creare le prenotazioni direttamente da un luogo per collegarle al tuo programma del giorno.',
+ 'reservations.confirmed': 'Confermata',
+ 'reservations.pending': 'In attesa',
+ 'reservations.summary': '{confirmed} confermate, {pending} in attesa',
+ 'reservations.fromPlan': 'Dal programma',
+ 'reservations.showFiles': 'Mostra file',
+ 'reservations.editTitle': 'Modifica prenotazione',
+ 'reservations.status': 'Stato',
+ 'reservations.datetime': 'Data e ora',
+ 'reservations.startTime': 'Ora di inizio',
+ 'reservations.endTime': 'Ora di fine',
+ 'reservations.date': 'Data',
+ 'reservations.time': 'Ora',
+ 'reservations.timeAlt': 'Ora (alternativa, es. 19:30)',
+ 'reservations.notes': 'Note',
+ 'reservations.notesPlaceholder': 'Note aggiuntive...',
+ 'reservations.meta.airline': 'Compagnia aerea',
+ 'reservations.meta.flightNumber': 'N. volo',
+ 'reservations.meta.from': 'Da',
+ 'reservations.meta.to': 'A',
+ 'reservations.meta.trainNumber': 'N. treno',
+ 'reservations.meta.platform': 'Binario',
+ 'reservations.meta.seat': 'Posto',
+ 'reservations.meta.checkIn': 'Check-in',
+ 'reservations.meta.checkOut': 'Check-out',
+ 'reservations.meta.linkAccommodation': 'Alloggio',
+ 'reservations.meta.pickAccommodation': 'Collega a un alloggio',
+ 'reservations.meta.noAccommodation': 'Nessuno',
+ 'reservations.meta.hotelPlace': 'Alloggio',
+ 'reservations.meta.pickHotel': 'Seleziona alloggio',
+ 'reservations.meta.fromDay': 'Da',
+ 'reservations.meta.toDay': 'A',
+ 'reservations.meta.selectDay': 'Seleziona giorno',
+ 'reservations.type.flight': 'Volo',
+ 'reservations.type.hotel': 'Alloggio',
+ 'reservations.type.restaurant': 'Ristorante',
+ 'reservations.type.train': 'Treno',
+ 'reservations.type.car': 'Auto a noleggio',
+ 'reservations.type.cruise': 'Crociera',
+ 'reservations.type.event': 'Evento',
+ 'reservations.type.tour': 'Tour',
+ 'reservations.type.other': 'Altro',
+ 'reservations.confirm.delete': 'Sei sicuro di voler eliminare la prenotazione "{name}"?',
+ 'reservations.confirm.deleteTitle': 'Eliminare la prenotazione?',
+ 'reservations.confirm.deleteBody': '"{name}" verrà eliminato in modo permanente.',
+ 'reservations.toast.updated': 'Prenotazione aggiornata',
+ 'reservations.toast.removed': 'Prenotazione eliminata',
+ 'reservations.toast.fileUploaded': 'File caricato',
+ 'reservations.toast.uploadError': 'Impossibile caricare',
+ 'reservations.newTitle': 'Nuova prenotazione',
+ 'reservations.bookingType': 'Tipo di prenotazione',
+ 'reservations.titleLabel': 'Titolo',
+ 'reservations.titlePlaceholder': 'es. Lufthansa LH123, Hotel Adlon, ...',
+ 'reservations.locationAddress': 'Posizione / Indirizzo',
+ 'reservations.locationPlaceholder': 'Indirizzo, aeroporto, hotel...',
+ 'reservations.confirmationCode': 'Codice prenotazione',
+ 'reservations.confirmationPlaceholder': 'es. ABC12345',
+ 'reservations.day': 'Giorno',
+ 'reservations.noDay': 'Nessun giorno',
+ 'reservations.place': 'Luogo',
+ 'reservations.noPlace': 'Nessun luogo',
+ 'reservations.pendingSave': 'verrà salvato…',
+ 'reservations.uploading': 'Caricamento...',
+ 'reservations.attachFile': 'Allega file',
+ 'reservations.linkExisting': 'Collega file esistente',
+ 'reservations.toast.saveError': 'Impossibile salvare',
+ 'reservations.toast.updateError': 'Impossibile aggiornare',
+ 'reservations.toast.deleteError': 'Impossibile eliminare',
+ 'reservations.confirm.remove': 'Rimuovere la prenotazione per "{name}"?',
+ 'reservations.linkAssignment': 'Collega all\'assegnazione del giorno',
+ 'reservations.pickAssignment': 'Seleziona un\'assegnazione dal tuo programma...',
+ 'reservations.noAssignment': 'Nessun collegamento (autonomo)',
+
+ // Budget
+ 'budget.title': 'Budget',
+ 'budget.emptyTitle': 'Ancora nessun budget creato',
+ 'budget.emptyText': 'Crea categorie e voci per pianificare il budget del tuo viaggio',
+ 'budget.emptyPlaceholder': 'Inserisci nome categoria...',
+ 'budget.createCategory': 'Crea categoria',
+ 'budget.category': 'Categoria',
+ 'budget.categoryName': 'Nome categoria',
+ 'budget.table.name': 'Nome',
+ 'budget.table.total': 'Totale',
+ 'budget.table.persons': 'Persone',
+ 'budget.table.days': 'Giorni',
+ 'budget.table.perPerson': 'Per persona',
+ 'budget.table.perDay': 'Per giorno',
+ 'budget.table.perPersonDay': 'P. p / gio.',
+ 'budget.table.note': 'Nota',
+ 'budget.newEntry': 'Nuova voce',
+ 'budget.defaultEntry': 'Nuova voce',
+ 'budget.defaultCategory': 'Nuova categoria',
+ 'budget.total': 'Totale',
+ 'budget.totalBudget': 'Budget totale',
+ 'budget.byCategory': 'Per categoria',
+ 'budget.editTooltip': 'Clicca per modificare',
+ 'budget.confirm.deleteCategory': 'Sei sicuro di voler eliminare la categoria "{name}" con {count} voci?',
+ 'budget.deleteCategory': 'Elimina categoria',
+ 'budget.perPerson': 'Per persona',
+ 'budget.paid': 'Pagato',
+ 'budget.open': 'Aperto',
+ 'budget.noMembers': 'Nessun membro assegnato',
+ 'budget.settlement': 'Regolamento',
+ 'budget.settlementInfo': 'Clicca sull\'avatar di un membro su una voce di budget per contrassegnarlo in verde — significa che ha pagato. Il regolamento mostra poi chi deve quanto a chi.',
+ 'budget.netBalances': 'Saldi netti',
+
+ // Files
+ 'files.title': 'File',
+ 'files.count': '{count} file',
+ 'files.countSingular': '1 file',
+ 'files.uploaded': '{count} caricati',
+ 'files.uploadError': 'Caricamento non riuscito',
+ 'files.dropzone': 'Trascina qui i file',
+ 'files.dropzoneHint': 'oppure clicca per sfogliare',
+ 'files.allowedTypes': 'Immagini, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB',
+ 'files.uploading': 'Caricamento...',
+ 'files.filterAll': 'Tutti',
+ 'files.filterPdf': 'PDF',
+ 'files.filterImages': 'Immagini',
+ 'files.filterDocs': 'Documenti',
+ 'files.filterCollab': 'Note Collaborazione',
+ 'files.sourceCollab': 'Da Note Collaborazione',
+ 'files.empty': 'Ancora nessun file',
+ 'files.emptyHint': 'Carica file per allegarli al tuo viaggio',
+ 'files.openTab': 'Apri in una nuova scheda',
+ 'files.confirm.delete': 'Sei sicuro di voler eliminare questo file?',
+ 'files.toast.deleted': 'File eliminato',
+ 'files.toast.deleteError': 'Impossibile eliminare il file',
+ 'files.sourcePlan': 'Programma giornaliero',
+ 'files.sourceBooking': 'Prenotazione',
+ 'files.attach': 'Allega',
+ 'files.pasteHint': 'Puoi anche incollare immagini dagli appunti (Ctrl+V)',
+ 'files.trash': 'Cestino',
+ 'files.trashEmpty': 'Il cestino è vuoto',
+ 'files.emptyTrash': 'Svuota cestino',
+ 'files.restore': 'Ripristina',
+ 'files.star': 'Aggiungi ai preferiti',
+ 'files.unstar': 'Rimuovi dai preferiti',
+ 'files.assign': 'Assegna',
+ 'files.assignTitle': 'Assegna file',
+ 'files.assignPlace': 'Luogo',
+ 'files.assignBooking': 'Prenotazione',
+ 'files.unassigned': 'Non assegnato',
+ 'files.unlink': 'Rimuovi collegamento',
+ 'files.toast.trashed': 'Spostato nel cestino',
+ 'files.toast.restored': 'File ripristinato',
+ 'files.toast.trashEmptied': 'Cestino svuotato',
+ 'files.toast.assigned': 'File assegnato',
+ 'files.toast.assignError': 'Assegnazione fallita',
+ 'files.toast.restoreError': 'Ripristino fallito',
+ 'files.confirm.permanentDelete': 'Eliminare questo file in modo permanente? Questa operazione non può essere annullata.',
+ 'files.confirm.emptyTrash': 'Eliminare in modo permanente tutti i file nel cestino? Questa operazione non può essere annullata.',
+ 'files.noteLabel': 'Nota',
+ 'files.notePlaceholder': 'Aggiungi una nota...',
+
+ // Packing
+ 'packing.title': 'Lista valigia',
+ 'packing.empty': 'La lista valigia è vuota',
+ 'packing.import': 'Importa',
+ 'packing.importTitle': 'Importa lista valigia',
+ 'packing.importHint': 'Un elemento per riga. Formato: Categoria, Nome, Peso in g (opzionale), Borsa (opzionale), checked/unchecked (opzionale)',
+ 'packing.importPlaceholder': 'Igiene, Spazzolino\nAbbigliamento, Magliette, 200\nDocumenti, Passaporto, , Bagaglio a mano\nElettronica, Caricabatterie, 50, Valigia, checked',
+ 'packing.importCsv': 'Carica CSV/TXT',
+ 'packing.importAction': 'Importa {count}',
+ 'packing.importSuccess': '{count} elementi importati',
+ 'packing.importError': 'Importazione non riuscita',
+ 'packing.importEmpty': 'Nessun elemento da importare',
+ 'packing.progress': '{packed} di {total} in valigia ({percent}%)',
+ 'packing.clearChecked': 'Rimuovi {count} spuntati',
+ 'packing.clearCheckedShort': 'Rimuovi {count}',
+ 'packing.suggestions': 'Suggerimenti',
+ 'packing.suggestionsTitle': 'Aggiungi suggerimenti',
+ 'packing.allSuggested': 'Tutti i suggerimenti aggiunti',
+ 'packing.allPacked': 'Tutto in valigia!',
+ 'packing.addPlaceholder': 'Aggiungi nuovo elemento...',
+ 'packing.categoryPlaceholder': 'Categoria...',
+ 'packing.filterAll': 'Tutti',
+ 'packing.filterOpen': 'Da fare',
+ 'packing.filterDone': 'Fatto',
+ 'packing.emptyTitle': 'La lista valigia è vuota',
+ 'packing.emptyHint': 'Aggiungi elementi o usa i suggerimenti',
+ 'packing.emptyFiltered': 'Nessun elemento corrisponde a questo filtro',
+ 'packing.menuRename': 'Rinomina',
+ 'packing.menuCheckAll': 'Seleziona tutti',
+ 'packing.menuUncheckAll': 'Deseleziona tutti',
+ 'packing.menuDeleteCat': 'Elimina categoria',
+ 'packing.assignUser': 'Assegna utente',
+ 'packing.noMembers': 'Nessun membro del viaggio',
+ 'packing.addItem': 'Aggiungi elemento',
+ 'packing.addItemPlaceholder': 'Nome elemento...',
+ 'packing.addCategory': 'Aggiungi categoria',
+ 'packing.newCategoryPlaceholder': 'Nome categoria (es. Abbigliamento)',
+ 'packing.applyTemplate': 'Applica modello',
+ 'packing.template': 'Modello',
+ 'packing.templateApplied': '{count} elementi aggiunti dal modello',
+ 'packing.templateError': 'Impossibile applicare il modello',
+ 'packing.bags': 'Valigie',
+ 'packing.noBag': 'Non assegnato',
+ 'packing.totalWeight': 'Peso totale',
+ 'packing.bagName': 'Nome valigia...',
+ 'packing.addBag': 'Aggiungi valigia',
+ 'packing.changeCategory': 'Cambia categoria',
+ 'packing.confirm.clearChecked': 'Sei sicuro di voler rimuovere {count} elementi spuntati?',
+ 'packing.confirm.deleteCat': 'Sei sicuro di voler eliminare la categoria "{name}" con {count} elementi?',
+ 'packing.defaultCategory': 'Altro',
+ 'packing.toast.saveError': 'Impossibile salvare',
+ 'packing.toast.deleteError': 'Impossibile eliminare',
+ 'packing.toast.renameError': 'Impossibile rinominare',
+ 'packing.toast.addError': 'Impossibile aggiungere',
+
+ // Packing suggestions
+ 'packing.suggestions.items': [
+ { name: 'Passaporto', category: 'Documenti' },
+ { name: 'Carta d\'identità', category: 'Documenti' },
+ { name: 'Assicurazione di viaggio', category: 'Documenti' },
+ { name: 'Biglietti aerei', category: 'Documenti' },
+ { name: 'Carta di credito', category: 'Finanze' },
+ { name: 'Contanti', category: 'Finanze' },
+ { name: 'Visto', category: 'Documenti' },
+ { name: 'Magliette', category: 'Abbigliamento' },
+ { name: 'Pantaloni', category: 'Abbigliamento' },
+ { name: 'Intimo', category: 'Abbigliamento' },
+ { name: 'Calzini', category: 'Abbigliamento' },
+ { name: 'Giacca', category: 'Abbigliamento' },
+ { name: 'Pigiama', category: 'Abbigliamento' },
+ { name: 'Costume da bagno', category: 'Abbigliamento' },
+ { name: 'Giacca a vento', category: 'Abbigliamento' },
+ { name: 'Scarpe comode', category: 'Abbigliamento' },
+ { name: 'Spazzolino da denti', category: 'Igiene personale' },
+ { name: 'Dentifricio', category: 'Igiene personale' },
+ { name: 'Shampoo', category: 'Igiene personale' },
+ { name: 'Deodorante', category: 'Igiene personale' },
+ { name: 'Crema solare', category: 'Igiene personale' },
+ { name: 'Rasoio', category: 'Igiene personale' },
+ { name: 'Caricabatterie', category: 'Elettronica' },
+ { name: 'Power bank', category: 'Elettronica' },
+ { name: 'Cuffie', category: 'Elettronica' },
+ { name: 'Adattatore da viaggio', category: 'Elettronica' },
+ { name: 'Macchina fotografica', category: 'Elettronica' },
+ { name: 'Antidolorifici', category: 'Salute' },
+ { name: 'Cerotti', category: 'Salute' },
+ { name: 'Disinfettante', category: 'Salute' },
+ ],
+
+ // Members / Sharing
+ 'members.shareTrip': 'Condividi viaggio',
+ 'members.inviteUser': 'Invita utente',
+ 'members.selectUser': 'Seleziona utente...',
+ 'members.invite': 'Invita',
+ 'members.allHaveAccess': 'Tutti gli utenti hanno già accesso.',
+ 'members.access': 'Accesso',
+ 'members.person': 'persona',
+ 'members.persons': 'persone',
+ 'members.you': 'tu',
+ 'members.owner': 'Proprietario',
+ 'members.leaveTrip': 'Abbandona viaggio',
+ 'members.removeAccess': 'Rimuovi accesso',
+ 'members.confirmLeave': 'Abbandonare il viaggio? Perderai l\'accesso.',
+ 'members.confirmRemove': 'Rimuovere l\'accesso per questo utente?',
+ 'members.loadError': 'Impossibile caricare i membri',
+ 'members.added': 'aggiunto',
+ 'members.addError': 'Impossibile aggiungere',
+ 'members.removed': 'Membro rimosso',
+ 'members.removeError': 'Impossibile rimuovere',
+
+ // Categories (Admin)
+ 'categories.title': 'Categorie',
+ 'categories.subtitle': 'Gestisci le categorie per i luoghi',
+ 'categories.new': 'Nuova categoria',
+ 'categories.empty': 'Ancora nessuna categoria',
+ 'categories.namePlaceholder': 'Nome categoria',
+ 'categories.icon': 'Icona',
+ 'categories.color': 'Colore',
+ 'categories.customColor': 'Scegli colore personalizzato',
+ 'categories.preview': 'Anteprima',
+ 'categories.defaultName': 'Categoria',
+ 'categories.update': 'Aggiorna',
+ 'categories.create': 'Crea',
+ 'categories.confirm.delete': 'Eliminare la categoria? I luoghi in questa categoria non verranno eliminati.',
+ 'categories.toast.loadError': 'Impossibile caricare le categorie',
+ 'categories.toast.nameRequired': 'Inserisci un nome',
+ 'categories.toast.updated': 'Categoria aggiornata',
+ 'categories.toast.created': 'Categoria creata',
+ 'categories.toast.saveError': 'Impossibile salvare',
+ 'categories.toast.deleted': 'Categoria eliminata',
+ 'categories.toast.deleteError': 'Impossibile eliminare',
+
+ // Backup (Admin)
+ 'backup.title': 'Backup dati',
+ 'backup.subtitle': 'Database e tutti i file caricati',
+ 'backup.refresh': 'Aggiorna',
+ 'backup.upload': 'Carica backup',
+ 'backup.uploading': 'Caricamento...',
+ 'backup.create': 'Crea backup',
+ 'backup.creating': 'Creazione...',
+ 'backup.empty': 'Ancora nessun backup',
+ 'backup.createFirst': 'Crea primo backup',
+ 'backup.download': 'Scarica',
+ 'backup.restore': 'Ripristina',
+ 'backup.confirm.restore': 'Ripristinare il backup "{name}"?\n\nTutti i dati attuali verranno sostituiti con il backup.',
+ 'backup.confirm.uploadRestore': 'Scaricare e ripristinare il file di backup "{name}"?\n\nTutti i dati attuali verranno sovrascritti.',
+ 'backup.confirm.delete': 'Eliminare il backup "{name}"?',
+ 'backup.toast.loadError': 'Impossibile caricare i backup',
+ 'backup.toast.created': 'Backup creato con successo',
+ 'backup.toast.createError': 'Impossibile creare il backup',
+ 'backup.toast.restored': 'Backup ripristinato. La pagina verrà ricaricata...',
+ 'backup.toast.restoreError': 'Impossibile ripristinare',
+ 'backup.toast.uploadError': 'Impossibile caricare',
+ 'backup.toast.deleted': 'Backup eliminato',
+ 'backup.toast.deleteError': 'Impossibile eliminare',
+ 'backup.toast.downloadError': 'Download non riuscito',
+ 'backup.toast.settingsSaved': 'Impostazioni auto-backup salvate',
+ 'backup.toast.settingsError': 'Impossibile salvare le impostazioni',
+ 'backup.auto.title': 'Auto-Backup',
+ 'backup.auto.subtitle': 'Backup automatico pianificato',
+ 'backup.auto.enable': 'Abilita auto-backup',
+ 'backup.auto.enableHint': 'I backup verranno creati automaticamente in base alla pianificazione scelta',
+ 'backup.auto.interval': 'Intervallo',
+ 'backup.auto.hour': 'Esegui all\'ora',
+ 'backup.auto.hourHint': 'Ora locale del server (formato {format})',
+ 'backup.auto.dayOfWeek': 'Giorno della settimana',
+ 'backup.auto.dayOfMonth': 'Giorno del mese',
+ 'backup.auto.dayOfMonthHint': 'Limitato a 1–28 per compatibilità con tutti i mesi',
+ 'backup.auto.scheduleSummary': 'Pianificazione',
+ 'backup.auto.summaryDaily': 'Ogni giorno alle {hour}:00',
+ 'backup.auto.summaryWeekly': 'Ogni {day} alle {hour}:00',
+ 'backup.auto.summaryMonthly': 'Giorno {day} di ogni mese alle {hour}:00',
+ 'backup.auto.envLocked': 'Docker',
+ 'backup.auto.envLockedHint': 'L\'auto-backup è configurato tramite variabili d\'ambiente Docker. Per modificare queste impostazioni, aggiorna il tuo docker-compose.yml e riavvia il container.',
+ 'backup.auto.copyEnv': 'Copia variabili env Docker',
+ 'backup.auto.envCopied': 'Variabili env Docker copiate negli appunti',
+ 'backup.auto.keepLabel': 'Elimina i vecchi backup dopo',
+ 'backup.dow.sunday': 'Dom',
+ 'backup.dow.monday': 'Lun',
+ 'backup.dow.tuesday': 'Mar',
+ 'backup.dow.wednesday': 'Mer',
+ 'backup.dow.thursday': 'Gio',
+ 'backup.dow.friday': 'Ven',
+ 'backup.dow.saturday': 'Sab',
+ 'backup.interval.hourly': 'Ogni ora',
+ 'backup.interval.daily': 'Giornaliero',
+ 'backup.interval.weekly': 'Settimanale',
+ 'backup.interval.monthly': 'Mensile',
+ 'backup.keep.1day': '1 giorno',
+ 'backup.keep.3days': '3 giorni',
+ 'backup.keep.7days': '7 giorni',
+ 'backup.keep.14days': '14 giorni',
+ 'backup.keep.30days': '30 giorni',
+ 'backup.keep.forever': 'Conserva per sempre',
+
+ // Photos
+ 'photos.allDays': 'Tutti i giorni',
+ 'photos.noPhotos': 'Ancora nessuna foto',
+ 'photos.uploadHint': 'Carica le foto del tuo viaggio',
+ 'photos.clickToSelect': 'o clicca per selezionare',
+ 'photos.linkPlace': 'Collega luogo',
+ 'photos.noPlace': 'Nessun luogo',
+ 'photos.uploadN': 'Caricamento di {n} foto',
+
+ // Backup restore modal
+ 'backup.restoreConfirmTitle': 'Ripristinare il backup?',
+ 'backup.restoreWarning': 'Tutti i dati attuali (viaggi, luoghi, utenti, caricamenti) verranno sostituiti in modo permanente dal backup. Questa azione non può essere annullata.',
+ 'backup.restoreTip': 'Suggerimento: Crea un backup dello stato attuale prima di ripristinare.',
+ 'backup.restoreConfirm': 'Sì, ripristina',
+
+ // PDF
+ 'pdf.travelPlan': 'Programma di viaggio',
+ 'pdf.planned': 'Programmato',
+ 'pdf.costLabel': 'Costo EUR',
+ 'pdf.preview': 'Anteprima PDF',
+ 'pdf.saveAsPdf': 'Salva come PDF',
+
+ // Planner
+ 'planner.places': 'Luoghi',
+ 'planner.bookings': 'Prenotazioni',
+ 'planner.packingList': 'Lista valigia',
+ 'planner.documents': 'Documenti',
+ 'planner.dayPlan': 'Programma giornaliero',
+ 'planner.reservations': 'Prenotazioni',
+ 'planner.minTwoPlaces': 'Servono almeno 2 luoghi con coordinate',
+ 'planner.noGeoPlaces': 'Nessun luogo con coordinate disponibile',
+ 'planner.routeCalculated': 'Percorso calcolato',
+ 'planner.routeCalcFailed': 'Il percorso non è stato calcolato',
+ 'planner.routeError': 'Errore nel calcolo del percorso',
+ 'planner.routeOptimized': 'Percorso ottimizzato',
+ 'planner.reservationUpdated': 'Prenotazione aggiornata',
+ 'planner.reservationAdded': 'Prenotazione aggiunta',
+ 'planner.confirmDeleteReservation': 'Eliminare la prenotazione?',
+ 'planner.reservationDeleted': 'Prenotazione eliminata',
+ 'planner.days': 'Giorni',
+ 'planner.allPlaces': 'Tutti i luoghi',
+ 'planner.totalPlaces': '{n} luoghi in totale',
+ 'planner.noDaysPlanned': 'Nessun giorno ancora programmato',
+ 'planner.editTrip': 'Modifica viaggio \u2192',
+ 'planner.placeOne': '1 luogo',
+ 'planner.placeN': '{n} luoghi',
+ 'planner.addNote': 'Aggiungi nota',
+ 'planner.noEntries': 'Nessuna voce per questo giorno',
+ 'planner.addPlace': 'Aggiungi luogo/attività',
+ 'planner.addPlaceShort': '+ Aggiungi luogo/attività',
+ 'planner.resPending': 'Prenotazione in attesa \u00B7 ',
+ 'planner.resConfirmed': 'Prenotazione confermata \u00B7 ',
+ 'planner.notePlaceholder': 'Nota\u2026',
+ 'planner.noteTimePlaceholder': 'Ora (opzionale)',
+ 'planner.noteExamplePlaceholder': 'es. S3 alle 14:30 dalla stazione centrale, traghetto dal molo 7, pausa pranzo\u2026',
+ 'planner.totalCost': 'Costo totale',
+ 'planner.searchPlaces': 'Cerca luoghi\u2026',
+ 'planner.allCategories': 'Tutte le categorie',
+ 'planner.noPlacesFound': 'Nessun luogo trovato',
+ 'planner.addFirstPlace': 'Aggiungi primo luogo',
+ 'planner.noReservations': 'Nessuna prenotazione',
+ 'planner.addFirstReservation': 'Aggiungi prima prenotazione',
+ 'planner.new': 'Nuovo',
+ 'planner.addToDay': '+ Giorno',
+ 'planner.calculating': 'Calcolo in corso\u2026',
+ 'planner.route': 'Percorso',
+ 'planner.optimize': 'Ottimizza',
+ 'planner.openGoogleMaps': 'Apri in Google Maps',
+ 'planner.selectDayHint': 'Seleziona un giorno dall\'elenco a sinistra per vedere il programma',
+ 'planner.noPlacesForDay': 'Ancora nessun luogo per questo giorno',
+ 'planner.addPlacesLink': 'Aggiungi luoghi \u2192',
+ 'planner.minTotal': 'min. totali',
+ 'planner.noReservation': 'Nessuna prenotazione',
+ 'planner.removeFromDay': 'Rimuovi dal giorno',
+ 'planner.addToThisDay': 'Aggiungi al giorno',
+ 'planner.overview': 'Panoramica',
+ 'planner.noDays': 'Ancora nessun giorno',
+ 'planner.editTripToAddDays': 'Modifica viaggio per aggiungere giorni',
+ 'planner.dayCount': '{n} Giorni',
+ 'planner.clickToUnlock': 'Clicca per sbloccare',
+ 'planner.keepPosition': 'Mantieni la posizione durante l\'ottimizzazione del percorso',
+ 'planner.dayDetails': 'Dettagli del giorno',
+ 'planner.dayN': 'Giorno {n}',
+
+ // Dashboard Stats
+ 'stats.countries': 'Paesi',
+ 'stats.cities': 'Città',
+ 'stats.trips': 'Viaggi',
+ 'stats.places': 'Luoghi',
+ 'stats.worldProgress': 'Progresso nel mondo',
+ 'stats.visited': 'visitati',
+ 'stats.remaining': 'rimanenti',
+ 'stats.visitedCountries': 'Paesi visitati',
+
+ // Day Detail Panel
+ 'day.precipProb': 'Probabilità di pioggia',
+ 'day.precipitation': 'Precipitazioni',
+ 'day.wind': 'Vento',
+ 'day.sunrise': 'Alba',
+ 'day.sunset': 'Tramonto',
+ 'day.hourlyForecast': 'Previsione oraria',
+ 'day.climateHint': 'Medie storiche \u2014 previsioni reali disponibili entro 16 giorni da questa data.',
+ 'day.noWeather': 'Nessun dato meteo disponibile. Aggiungi un luogo con coordinate.',
+ 'day.overview': 'Panoramica giornaliera',
+ 'day.accommodation': 'Alloggio',
+ 'day.addAccommodation': 'Aggiungi alloggio',
+ 'day.hotelDayRange': 'Applica ai giorni',
+ 'day.noPlacesForHotel': 'Aggiungi prima i luoghi al tuo viaggio',
+ 'day.allDays': 'Tutti',
+ 'day.checkIn': 'Check-in',
+ 'day.checkOut': 'Check-out',
+ 'day.confirmation': 'Conferma',
+ 'day.editAccommodation': 'Modifica alloggio',
+ 'day.reservations': 'Prenotazioni',
+
+ // Photos / Immich
+ 'memories.title': 'Foto',
+ 'memories.notConnected': 'Immich non connesso',
+ 'memories.notConnectedHint': 'Connetti la tua istanza Immich nelle Impostazioni per vedere qui le foto del tuo viaggio.',
+ 'memories.noDates': 'Aggiungi le date al tuo viaggio per caricare le foto.',
+ 'memories.noPhotos': 'Nessuna foto trovata',
+ 'memories.noPhotosHint': 'Nessuna foto trovata in Immich per l\'intervallo di date di questo viaggio.',
+ 'memories.photosFound': 'foto',
+ 'memories.fromOthers': 'da altri',
+ 'memories.sharePhotos': 'Condividi foto',
+ 'memories.sharing': 'Condivisione',
+ 'memories.reviewTitle': 'Rivedi le tue foto',
+ 'memories.reviewHint': 'Clicca sulle foto per escluderle dalla condivisione.',
+ 'memories.shareCount': 'Condividi {count} foto',
+ 'memories.immichUrl': 'URL Server Immich',
+ 'memories.immichApiKey': 'Chiave API',
+ 'memories.testConnection': 'Test connessione',
+ 'memories.connected': 'Connesso',
+ 'memories.disconnected': 'Non connesso',
+ 'memories.connectionSuccess': 'Connesso a Immich',
+ 'memories.connectionError': 'Impossibile connettersi a Immich',
+ 'memories.saved': 'Impostazioni Immich salvate',
+ 'memories.addPhotos': 'Aggiungi foto',
+ 'memories.selectPhotos': 'Seleziona foto da Immich',
+ 'memories.selectHint': 'Tocca le foto per selezionarle.',
+ 'memories.selected': 'selezionate',
+ 'memories.addSelected': 'Aggiungi {count} foto',
+ 'memories.alreadyAdded': 'Aggiunta',
+ 'memories.private': 'Privato',
+ 'memories.stopSharing': 'Interrompi condivisione',
+ 'memories.oldest': 'Prima le più vecchie',
+ 'memories.newest': 'Prima le più recenti',
+ 'memories.allLocations': 'Tutte le posizioni',
+ 'memories.tripDates': 'Date del viaggio',
+ 'memories.allPhotos': 'Tutte le foto',
+ 'memories.confirmShareTitle': 'Condividere con i membri del viaggio?',
+ 'memories.confirmShareHint': '{count} foto saranno visibili a tutti i membri di questo viaggio. Potrai rendere private le singole foto in seguito.',
+ 'memories.confirmShareButton': 'Condividi foto',
+
+ // Collab Addon
+ 'collab.tabs.chat': 'Chat',
+ 'collab.tabs.notes': 'Note',
+ 'collab.tabs.polls': 'Sondaggi',
+ 'collab.whatsNext.title': "Cosa c'è dopo",
+ 'collab.whatsNext.today': 'Oggi',
+ 'collab.whatsNext.tomorrow': 'Domani',
+ 'collab.whatsNext.empty': 'Nessuna attività imminente',
+ 'collab.whatsNext.until': 'a',
+ 'collab.whatsNext.emptyHint': 'Le attività con orari appariranno qui',
+ 'collab.chat.send': 'Invia',
+ 'collab.chat.placeholder': 'Scrivi un messaggio...',
+ 'collab.chat.empty': 'Inizia la conversazione',
+ 'collab.chat.emptyHint': 'I messaggi sono condivisi con tutti i membri del viaggio',
+ 'collab.chat.emptyDesc': 'Condividi idee, programmi e aggiornamenti con il tuo gruppo di viaggio',
+ 'collab.chat.today': 'Oggi',
+ 'collab.chat.yesterday': 'Ieri',
+ 'collab.chat.deletedMessage': 'ha eliminato un messaggio',
+ 'collab.chat.loadMore': 'Carica messaggi precedenti',
+ 'collab.chat.justNow': 'ora',
+ 'collab.chat.minutesAgo': '{n}m fa',
+ 'collab.chat.hoursAgo': '{n}h fa',
+ 'collab.notes.title': 'Note',
+ 'collab.notes.new': 'Nuova nota',
+ 'collab.notes.empty': 'Ancora nessuna nota',
+ 'collab.notes.emptyHint': 'Inizia a raccogliere idee e programmi',
+ 'collab.notes.all': 'Tutte',
+ 'collab.notes.titlePlaceholder': 'Titolo della nota',
+ 'collab.notes.contentPlaceholder': 'Scrivi qualcosa...',
+ 'collab.notes.categoryPlaceholder': 'Categoria',
+ 'collab.notes.newCategory': 'Nuova categoria...',
+ 'collab.notes.category': 'Categoria',
+ 'collab.notes.noCategory': 'Nessuna categoria',
+ 'collab.notes.color': 'Colore',
+ 'collab.notes.save': 'Salva',
+ 'collab.notes.cancel': 'Annulla',
+ 'collab.notes.edit': 'Modifica',
+ 'collab.notes.delete': 'Elimina',
+ 'collab.notes.pin': 'Fissa',
+ 'collab.notes.unpin': 'Rimuovi',
+ 'collab.notes.daysAgo': '{n}g fa',
+ 'collab.notes.categorySettings': 'Gestisci categorie',
+ 'collab.notes.create': 'Crea',
+ 'collab.notes.website': 'Sito web',
+ 'collab.notes.websitePlaceholder': 'https://...',
+ 'collab.notes.attachFiles': 'Allega file',
+ 'collab.notes.noCategoriesYet': 'Ancora nessuna categoria',
+ 'collab.notes.emptyDesc': 'Crea una nota per iniziare',
+ 'collab.polls.title': 'Sondaggi',
+ 'collab.polls.new': 'Nuovo sondaggio',
+ 'collab.polls.empty': 'Ancora nessun sondaggio',
+ 'collab.polls.emptyHint': 'Chiedi al gruppo e votate insieme',
+ 'collab.polls.question': 'Domanda',
+ 'collab.polls.questionPlaceholder': 'Cosa dovremmo fare?',
+ 'collab.polls.addOption': '+ Aggiungi opzione',
+ 'collab.polls.optionPlaceholder': 'Opzione {n}',
+ 'collab.polls.create': 'Crea sondaggio',
+ 'collab.polls.close': 'Chiudi',
+ 'collab.polls.closed': 'Chiuso',
+ 'collab.polls.votes': '{n} voti',
+ 'collab.polls.vote': '{n} voto',
+ 'collab.polls.multipleChoice': 'Scelta multipla',
+ 'collab.polls.multiChoice': 'Scelta multipla',
+ 'collab.polls.deadline': 'Scadenza',
+ 'collab.polls.option': 'Opzione',
+ 'collab.polls.options': 'Opzioni',
+ 'collab.polls.delete': 'Elimina',
+ 'collab.polls.closedSection': 'Chiusi',
+}
+
+export default it
\ No newline at end of file
diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts
index add8dd6..43a657c 100644
--- a/client/src/i18n/translations/nl.ts
+++ b/client/src/i18n/translations/nl.ts
@@ -139,8 +139,77 @@ const nl: Record = {
'settings.temperature': 'Temperatuureenheid',
'settings.timeFormat': 'Tijdnotatie',
'settings.routeCalculation': 'Routeberekening',
+ 'settings.blurBookingCodes': 'Boekingscodes vervagen',
+ 'settings.notifications': 'Meldingen',
+ 'settings.notifyTripInvite': 'Reisuitnodigingen',
+ 'settings.notifyBookingChange': 'Boekingswijzigingen',
+ 'settings.notifyTripReminder': 'Reisherinneringen',
+ 'settings.notifyVacayInvite': 'Vacay-fusieuitnodigingen',
+ 'settings.notifyPhotosShared': 'Gedeelde foto\'s (Immich)',
+ 'settings.notifyCollabMessage': 'Chatberichten (Collab)',
+ 'settings.notifyPackingTagged': 'Paklijst: toewijzingen',
+ 'settings.notifyWebhook': 'Webhook-meldingen',
+ 'admin.smtp.title': 'E-mail en meldingen',
+ 'admin.smtp.hint': 'SMTP-configuratie voor e-mailmeldingen. Optioneel: Webhook-URL voor Discord, Slack, etc.',
+ 'admin.smtp.testButton': 'Test-e-mail verzenden',
+ 'admin.smtp.testSuccess': 'Test-e-mail succesvol verzonden',
+ 'admin.smtp.testFailed': 'Test-e-mail mislukt',
+ 'dayplan.icsTooltip': 'Kalender exporteren (ICS)',
+ 'share.linkTitle': 'Openbare link',
+ 'share.linkHint': 'Maak een link die iedereen kan gebruiken om deze reis te bekijken zonder in te loggen. Alleen-lezen — bewerken niet mogelijk.',
+ 'share.createLink': 'Link aanmaken',
+ 'share.deleteLink': 'Link verwijderen',
+ 'share.createError': 'Kon link niet aanmaken',
+ 'common.copy': 'Kopiëren',
+ 'common.copied': 'Gekopieerd',
+ 'share.permMap': 'Kaart en plan',
+ 'share.permBookings': 'Boekingen',
+ 'share.permPacking': 'Paklijst',
+ 'shared.expired': 'Link verlopen of ongeldig',
+ 'shared.expiredHint': 'Deze gedeelde reislink is niet meer actief.',
+ 'shared.readOnly': 'Alleen-lezen weergave',
+ 'shared.tabPlan': 'Plan',
+ 'shared.tabBookings': 'Boekingen',
+ 'shared.tabPacking': 'Paklijst',
+ 'shared.tabBudget': 'Budget',
+ 'shared.tabChat': 'Chat',
+ 'shared.days': 'dagen',
+ 'shared.places': 'plaatsen',
+ 'shared.other': 'Overig',
+ 'shared.totalBudget': 'Totaal budget',
+ 'shared.messages': 'berichten',
+ 'shared.sharedVia': 'Gedeeld via',
+ 'shared.confirmed': 'Bevestigd',
+ 'shared.pending': 'In afwachting',
+ 'share.permBudget': 'Budget',
+ 'share.permCollab': 'Chat',
'settings.on': 'Aan',
'settings.off': 'Uit',
+ 'settings.mcp.title': 'MCP-configuratie',
+ 'settings.mcp.endpoint': 'MCP-eindpunt',
+ 'settings.mcp.clientConfig': 'Clientconfiguratie',
+ 'settings.mcp.clientConfigHint': 'Vervang door een API-token uit de onderstaande lijst. Het pad naar npx moet mogelijk worden aangepast voor jouw systeem (bijv. C:\\PROGRA~1\\nodejs\\npx.cmd op Windows).',
+ 'settings.mcp.copy': 'Kopiëren',
+ 'settings.mcp.copied': 'Gekopieerd!',
+ 'settings.mcp.apiTokens': 'API-tokens',
+ 'settings.mcp.createToken': 'Nieuw token aanmaken',
+ 'settings.mcp.noTokens': 'Nog geen tokens. Maak er een aan om MCP-clients te verbinden.',
+ 'settings.mcp.tokenCreatedAt': 'Aangemaakt',
+ 'settings.mcp.tokenUsedAt': 'Gebruikt',
+ 'settings.mcp.deleteTokenTitle': 'Token verwijderen',
+ 'settings.mcp.deleteTokenMessage': 'Dit token werkt onmiddellijk niet meer. Elke MCP-client die het gebruikt verliest de toegang.',
+ 'settings.mcp.modal.createTitle': 'API-token aanmaken',
+ 'settings.mcp.modal.tokenName': 'Tokennaam',
+ 'settings.mcp.modal.tokenNamePlaceholder': 'bijv. Claude Desktop, Werklaptop',
+ 'settings.mcp.modal.creating': 'Aanmaken…',
+ 'settings.mcp.modal.create': 'Token aanmaken',
+ 'settings.mcp.modal.createdTitle': 'Token aangemaakt',
+ 'settings.mcp.modal.createdWarning': 'Dit token wordt slechts één keer getoond. Kopieer en bewaar het nu — het kan niet worden hersteld.',
+ 'settings.mcp.modal.done': 'Klaar',
+ 'settings.mcp.toast.created': 'Token aangemaakt',
+ 'settings.mcp.toast.createError': 'Token aanmaken mislukt',
+ 'settings.mcp.toast.deleted': 'Token verwijderd',
+ 'settings.mcp.toast.deleteError': 'Token verwijderen mislukt',
'settings.account': 'Account',
'settings.username': 'Gebruikersnaam',
'settings.email': 'E-mail',
@@ -168,6 +237,14 @@ const nl: Record = {
'settings.saveProfile': 'Profiel opslaan',
'settings.mfa.title': 'Tweefactorauthenticatie (2FA)',
'settings.mfa.description': 'Voegt een tweede stap toe bij het inloggen. Gebruik een authenticator-app (Google Authenticator, Authy, etc.).',
+ 'settings.mfa.requiredByPolicy': 'Je beheerder vereist tweestapsverificatie. Stel hieronder een authenticator-app in voordat je verdergaat.',
+ 'settings.mfa.backupTitle': 'Back-upcodes',
+ 'settings.mfa.backupDescription': 'Gebruik deze eenmalige codes als je geen toegang meer hebt tot je authenticator-app.',
+ 'settings.mfa.backupWarning': 'Sla deze codes nu op. Elke code kan maar een keer worden gebruikt.',
+ 'settings.mfa.backupCopy': 'Codes kopiëren',
+ 'settings.mfa.backupDownload': 'TXT downloaden',
+ 'settings.mfa.backupPrint': 'Afdrukken / PDF',
+ 'settings.mfa.backupCopied': 'Back-upcodes gekopieerd',
'settings.mfa.enabled': '2FA is ingeschakeld op je account.',
'settings.mfa.disabled': '2FA is niet ingeschakeld.',
'settings.mfa.setup': 'Authenticator instellen',
@@ -271,6 +348,7 @@ const nl: Record = {
'admin.tabs.users': 'Gebruikers',
'admin.tabs.categories': 'Categorieën',
'admin.tabs.backup': 'Back-up',
+ 'admin.tabs.audit': 'Auditlog',
'admin.stats.users': 'Gebruikers',
'admin.stats.trips': 'Reizen',
'admin.stats.places': 'Plaatsen',
@@ -320,6 +398,8 @@ const nl: Record = {
'admin.tabs.settings': 'Instellingen',
'admin.allowRegistration': 'Registratie toestaan',
'admin.allowRegistrationHint': 'Nieuwe gebruikers kunnen zichzelf registreren',
+ 'admin.requireMfa': 'Tweestapsverificatie (2FA) verplicht stellen',
+ 'admin.requireMfaHint': 'Gebruikers zonder 2FA moeten de installatie in Instellingen voltooien voordat ze de app kunnen gebruiken.',
'admin.apiKeys': 'API-sleutels',
'admin.apiKeysHint': 'Optioneel. Schakelt uitgebreide plaatsgegevens in zoals foto\'s en weer.',
'admin.mapsKey': 'Google Maps API-sleutel',
@@ -375,6 +455,8 @@ const nl: Record = {
'admin.addons.subtitle': 'Schakel functies in of uit om je TREK-ervaring aan te passen.',
'admin.addons.catalog.memories.name': 'Foto\'s (Immich)',
'admin.addons.catalog.memories.description': 'Deel reisfoto\'s via je Immich-instantie',
+ 'admin.addons.catalog.mcp.name': 'MCP',
+ 'admin.addons.catalog.mcp.description': 'Model Context Protocol voor AI-assistent integratie',
'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',
@@ -393,8 +475,10 @@ const nl: Record = {
'admin.addons.disabled': 'Uitgeschakeld',
'admin.addons.type.trip': 'Reis',
'admin.addons.type.global': 'Globaal',
+ 'admin.addons.type.integration': 'Integratie',
'admin.addons.tripHint': 'Beschikbaar als tabblad binnen elke reis',
'admin.addons.globalHint': 'Beschikbaar als zelfstandig onderdeel in de hoofdnavigatie',
+ 'admin.addons.integrationHint': 'Backenddiensten en API-integraties zonder eigen pagina',
'admin.addons.toast.updated': 'Add-on bijgewerkt',
'admin.addons.toast.error': 'Add-on bijwerken mislukt',
'admin.addons.noAddons': 'Geen add-ons beschikbaar',
@@ -410,8 +494,37 @@ const nl: Record = {
'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.',
+ // MCP Tokens
+ 'admin.tabs.mcpTokens': 'MCP-tokens',
+ 'admin.mcpTokens.title': 'MCP-tokens',
+ 'admin.mcpTokens.subtitle': 'API-tokens van alle gebruikers beheren',
+ 'admin.mcpTokens.owner': 'Eigenaar',
+ 'admin.mcpTokens.tokenName': 'Tokennaam',
+ 'admin.mcpTokens.created': 'Aangemaakt',
+ 'admin.mcpTokens.lastUsed': 'Laatst gebruikt',
+ 'admin.mcpTokens.never': 'Nooit',
+ 'admin.mcpTokens.empty': 'Er zijn nog geen MCP-tokens aangemaakt',
+ 'admin.mcpTokens.deleteTitle': 'Token verwijderen',
+ 'admin.mcpTokens.deleteMessage': 'Dit token wordt onmiddellijk ingetrokken. De gebruiker verliest MCP-toegang via dit token.',
+ 'admin.mcpTokens.deleteSuccess': 'Token verwijderd',
+ 'admin.mcpTokens.deleteError': 'Token kon niet worden verwijderd',
+ 'admin.mcpTokens.loadError': 'Tokens konden niet worden geladen',
+
// GitHub
'admin.tabs.github': 'GitHub',
+
+ 'admin.audit.subtitle': 'Beveiligingsgevoelige en beheerdersgebeurtenissen (back-ups, gebruikers, MFA, instellingen).',
+ 'admin.audit.empty': 'Nog geen auditregistraties.',
+ 'admin.audit.refresh': 'Vernieuwen',
+ 'admin.audit.loadMore': 'Meer laden',
+ 'admin.audit.showing': '{count} geladen · {total} totaal',
+ 'admin.audit.col.time': 'Tijd',
+ 'admin.audit.col.user': 'Gebruiker',
+ 'admin.audit.col.action': 'Actie',
+ 'admin.audit.col.resource': 'Bron',
+ 'admin.audit.col.ip': 'IP',
+ 'admin.audit.col.details': 'Details',
+
'admin.github.title': 'Release-geschiedenis',
'admin.github.subtitle': 'Laatste updates van {repo}',
'admin.github.latest': 'Nieuwste',
@@ -475,6 +588,14 @@ const nl: Record = {
'vacay.carriedOver': 'van {year}',
'vacay.blockWeekends': 'Weekenden blokkeren',
'vacay.blockWeekendsHint': 'Voorkom vakantie-invoeren op zaterdag en zondag',
+ 'vacay.weekendDays': 'Weekenddagen',
+ 'vacay.mon': 'Ma',
+ 'vacay.tue': 'Di',
+ 'vacay.wed': 'Wo',
+ 'vacay.thu': 'Do',
+ 'vacay.fri': 'Vr',
+ 'vacay.sat': 'Za',
+ 'vacay.sun': 'Zo',
'vacay.publicHolidays': 'Feestdagen',
'vacay.publicHolidaysHint': 'Markeer feestdagen in de kalender',
'vacay.selectCountry': 'Selecteer land',
@@ -569,6 +690,10 @@ const nl: Record = {
'atlas.markVisited': 'Markeren als bezocht',
'atlas.markVisitedHint': 'Dit land toevoegen aan je bezochte lijst',
'atlas.addToBucket': 'Aan bucket list toevoegen',
+ 'atlas.addPoi': 'Plaats toevoegen',
+ 'atlas.bucketNamePlaceholder': 'Naam (land, stad, plek…)',
+ 'atlas.month': 'Maand',
+ 'atlas.year': 'Jaar',
'atlas.addToBucketHint': 'Opslaan als plek die je wilt bezoeken',
'atlas.bucketWhen': 'Wanneer ben je van plan te gaan?',
@@ -618,14 +743,26 @@ const nl: Record = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Dagplan exporteren als PDF',
'dayplan.pdfError': 'PDF-export mislukt',
+ 'dayplan.cannotReorderTransport': 'Boekingen met een vast tijdstip kunnen niet worden verplaatst',
+ 'dayplan.confirmRemoveTimeTitle': 'Tijd verwijderen?',
+ 'dayplan.confirmRemoveTimeBody': 'Deze plek heeft een vast tijdstip ({time}). Verplaatsen verwijdert het tijdstip en maakt vrije sortering mogelijk.',
+ 'dayplan.confirmRemoveTimeAction': 'Tijd verwijderen en verplaatsen',
+ 'dayplan.cannotDropOnTimed': 'Items kunnen niet tussen tijdgebonden items worden geplaatst',
+ 'dayplan.cannotBreakChronology': 'Dit zou de chronologische volgorde van geplande items en boekingen doorbreken',
// Places Sidebar
'places.addPlace': 'Plaats/activiteit toevoegen',
+ 'places.importGpx': 'GPX importeren',
+ 'places.gpxImported': '{count} plaatsen geïmporteerd uit GPX',
+ 'places.gpxError': 'GPX-import mislukt',
+ 'places.urlResolved': 'Plaats geïmporteerd van URL',
'places.assignToDay': 'Aan welke dag toevoegen?',
'places.all': 'Alle',
'places.unplanned': 'Ongepland',
'places.search': 'Plaatsen zoeken...',
'places.allCategories': 'Alle categorieën',
+ 'places.categoriesSelected': 'categorieën',
+ 'places.clearFilter': 'Filter wissen',
'places.count': '{count} plaatsen',
'places.countSingular': '1 plaats',
'places.allPlanned': 'Alle plaatsen zijn gepland',
@@ -724,6 +861,8 @@ const nl: Record = {
'reservations.type.tour': 'Rondleiding',
'reservations.type.other': 'Overig',
'reservations.confirm.delete': 'Weet je zeker dat je de reservering "{name}" wilt verwijderen?',
+ 'reservations.confirm.deleteTitle': 'Boeking verwijderen?',
+ 'reservations.confirm.deleteBody': '"{name}" wordt permanent verwijderd.',
'reservations.toast.updated': 'Reservering bijgewerkt',
'reservations.toast.removed': 'Reservering verwijderd',
'reservations.toast.fileUploaded': 'Bestand geüpload',
@@ -781,6 +920,9 @@ const nl: Record = {
'budget.paid': 'Betaald',
'budget.open': 'Open',
'budget.noMembers': 'Geen leden toegewezen',
+ 'budget.settlement': 'Afrekening',
+ 'budget.settlementInfo': 'Klik op de avatar van een lid bij een budgetpost om deze groen te markeren — dit betekent dat diegene heeft betaald. De afrekening toont vervolgens wie wie hoeveel verschuldigd is.',
+ 'budget.netBalances': 'Nettosaldi',
// Files
'files.title': 'Bestanden',
@@ -834,6 +976,15 @@ const nl: Record = {
// Packing
'packing.title': 'Paklijst',
'packing.empty': 'Paklijst is leeg',
+ 'packing.import': 'Importeren',
+ 'packing.importTitle': 'Paklijst importeren',
+ 'packing.importHint': 'Eén item per regel. Optioneel categorie en aantal gescheiden door komma, puntkomma of tab: Naam, Categorie, Aantal',
+ 'packing.importPlaceholder': 'Tandenborstel\nZonnebrand, Hygiëne\nT-Shirts, Kleding, 5\nPaspoort, Documenten',
+ 'packing.importCsv': 'CSV/TXT laden',
+ 'packing.importAction': '{count} importeren',
+ 'packing.importSuccess': '{count} items geïmporteerd',
+ 'packing.importError': 'Import mislukt',
+ 'packing.importEmpty': 'Geen items om te importeren',
'packing.progress': '{packed} van {total} ingepakt ({percent}%)',
'packing.clearChecked': '{count} aangevinkte verwijderen',
'packing.clearCheckedShort': '{count} verwijderen',
@@ -985,7 +1136,27 @@ const nl: Record = {
'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.hour': 'Uitvoeren om',
+ 'backup.auto.hourHint': 'Lokale servertijd ({format}-notatie)',
+ 'backup.auto.dayOfWeek': 'Dag van de week',
+ 'backup.auto.dayOfMonth': 'Dag van de maand',
+ 'backup.auto.dayOfMonthHint': 'Beperkt tot 1–28 voor compatibiliteit met alle maanden',
+ 'backup.auto.scheduleSummary': 'Planning',
+ 'backup.auto.summaryDaily': 'Elke dag om {hour}:00',
+ 'backup.auto.summaryWeekly': 'Elke {day} om {hour}:00',
+ 'backup.auto.summaryMonthly': 'Dag {day} van elke maand om {hour}:00',
+ 'backup.auto.envLocked': 'Docker',
+ 'backup.auto.envLockedHint': 'Auto-back-up is geconfigureerd via Docker-omgevingsvariabelen. Pas je docker-compose.yml aan en herstart de container om deze instellingen te wijzigen.',
+ 'backup.auto.copyEnv': 'Docker-omgevingsvariabelen kopiëren',
+ 'backup.auto.envCopied': 'Docker-omgevingsvariabelen gekopieerd naar klembord',
'backup.auto.keepLabel': 'Oude back-ups verwijderen na',
+ 'backup.dow.sunday': 'Zo',
+ 'backup.dow.monday': 'Ma',
+ 'backup.dow.tuesday': 'Di',
+ 'backup.dow.wednesday': 'Wo',
+ 'backup.dow.thursday': 'Do',
+ 'backup.dow.friday': 'Vr',
+ 'backup.dow.saturday': 'Za',
'backup.interval.hourly': 'Elk uur',
'backup.interval.daily': 'Dagelijks',
'backup.interval.weekly': 'Wekelijks',
@@ -1137,6 +1308,19 @@ const nl: Record = {
'memories.oldest': 'Oudste eerst',
'memories.newest': 'Nieuwste eerst',
'memories.allLocations': 'Alle locaties',
+ 'memories.addPhotos': 'Foto\'s toevoegen',
+ 'memories.selectPhotos': 'Selecteer foto\'s uit Immich',
+ 'memories.selectHint': 'Tik op foto\'s om ze te selecteren.',
+ 'memories.selected': 'geselecteerd',
+ 'memories.addSelected': '{count} foto\'s toevoegen',
+ 'memories.alreadyAdded': 'Toegevoegd',
+ 'memories.private': 'Privé',
+ 'memories.stopSharing': 'Delen stoppen',
+ 'memories.tripDates': 'Reisdata',
+ 'memories.allPhotos': 'Alle foto\'s',
+ 'memories.confirmShareTitle': 'Delen met reisgenoten?',
+ 'memories.confirmShareHint': '{count} foto\'s worden zichtbaar voor alle leden van deze reis. Je kunt individuele foto\'s later privé maken.',
+ 'memories.confirmShareButton': 'Foto\'s delen',
// Collab Addon
'collab.tabs.chat': 'Chat',
diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts
index 039f8dc..15854e9 100644
--- a/client/src/i18n/translations/ru.ts
+++ b/client/src/i18n/translations/ru.ts
@@ -139,8 +139,77 @@ const ru: Record = {
'settings.temperature': 'Единица температуры',
'settings.timeFormat': 'Формат времени',
'settings.routeCalculation': 'Расчёт маршрута',
+ 'settings.blurBookingCodes': 'Скрыть коды бронирования',
+ 'settings.notifications': 'Уведомления',
+ 'settings.notifyTripInvite': 'Приглашения в поездку',
+ 'settings.notifyBookingChange': 'Изменения бронирований',
+ 'settings.notifyTripReminder': 'Напоминания о поездке',
+ 'settings.notifyVacayInvite': 'Приглашения слияния Vacay',
+ 'settings.notifyPhotosShared': 'Общие фото (Immich)',
+ 'settings.notifyCollabMessage': 'Сообщения чата (Collab)',
+ 'settings.notifyPackingTagged': 'Список вещей: назначения',
+ 'settings.notifyWebhook': 'Webhook-уведомления',
+ 'admin.smtp.title': 'Почта и уведомления',
+ 'admin.smtp.hint': 'Настройка SMTP для уведомлений по почте. Необязательно: Webhook URL для Discord, Slack и т.д.',
+ 'admin.smtp.testButton': 'Отправить тестовое письмо',
+ 'admin.smtp.testSuccess': 'Тестовое письмо успешно отправлено',
+ 'admin.smtp.testFailed': 'Ошибка отправки тестового письма',
+ 'dayplan.icsTooltip': 'Экспорт календаря (ICS)',
+ 'share.linkTitle': 'Публичная ссылка',
+ 'share.linkHint': 'Создайте ссылку, по которой любой сможет просмотреть эту поездку без входа в систему. Только чтение — редактирование невозможно.',
+ 'share.createLink': 'Создать ссылку',
+ 'share.deleteLink': 'Удалить ссылку',
+ 'share.createError': 'Не удалось создать ссылку',
+ 'common.copy': 'Копировать',
+ 'common.copied': 'Скопировано',
+ 'share.permMap': 'Карта и план',
+ 'share.permBookings': 'Бронирования',
+ 'share.permPacking': 'Вещи',
+ 'shared.expired': 'Ссылка устарела или недействительна',
+ 'shared.expiredHint': 'Эта ссылка на поездку больше не активна.',
+ 'shared.readOnly': 'Режим только для чтения',
+ 'shared.tabPlan': 'План',
+ 'shared.tabBookings': 'Бронирования',
+ 'shared.tabPacking': 'Багаж',
+ 'shared.tabBudget': 'Бюджет',
+ 'shared.tabChat': 'Чат',
+ 'shared.days': 'дней',
+ 'shared.places': 'мест',
+ 'shared.other': 'Прочее',
+ 'shared.totalBudget': 'Общий бюджет',
+ 'shared.messages': 'сообщений',
+ 'shared.sharedVia': 'Поделено через',
+ 'shared.confirmed': 'Подтверждено',
+ 'shared.pending': 'Ожидает',
+ 'share.permBudget': 'Бюджет',
+ 'share.permCollab': 'Чат',
'settings.on': 'Вкл.',
'settings.off': 'Выкл.',
+ 'settings.mcp.title': 'Настройка MCP',
+ 'settings.mcp.endpoint': 'MCP-эндпоинт',
+ 'settings.mcp.clientConfig': 'Конфигурация клиента',
+ 'settings.mcp.clientConfigHint': 'Замените на API-токен из списка ниже. Путь к npx может потребовать настройки для вашей системы (например, C:\\PROGRA~1\\nodejs\\npx.cmd в Windows).',
+ 'settings.mcp.copy': 'Копировать',
+ 'settings.mcp.copied': 'Скопировано!',
+ 'settings.mcp.apiTokens': 'API-токены',
+ 'settings.mcp.createToken': 'Создать токен',
+ 'settings.mcp.noTokens': 'Токенов пока нет. Создайте один для подключения MCP-клиентов.',
+ 'settings.mcp.tokenCreatedAt': 'Создан',
+ 'settings.mcp.tokenUsedAt': 'Использован',
+ 'settings.mcp.deleteTokenTitle': 'Удалить токен',
+ 'settings.mcp.deleteTokenMessage': 'Этот токен перестанет работать немедленно. Любой MCP-клиент, использующий его, потеряет доступ.',
+ 'settings.mcp.modal.createTitle': 'Создать API-токен',
+ 'settings.mcp.modal.tokenName': 'Название токена',
+ 'settings.mcp.modal.tokenNamePlaceholder': 'напр. Claude Desktop, Рабочий ноутбук',
+ 'settings.mcp.modal.creating': 'Создание…',
+ 'settings.mcp.modal.create': 'Создать токен',
+ 'settings.mcp.modal.createdTitle': 'Токен создан',
+ 'settings.mcp.modal.createdWarning': 'Этот токен будет показан только один раз. Скопируйте и сохраните его сейчас — восстановить его будет невозможно.',
+ 'settings.mcp.modal.done': 'Готово',
+ 'settings.mcp.toast.created': 'Токен создан',
+ 'settings.mcp.toast.createError': 'Не удалось создать токен',
+ 'settings.mcp.toast.deleted': 'Токен удалён',
+ 'settings.mcp.toast.deleteError': 'Не удалось удалить токен',
'settings.account': 'Аккаунт',
'settings.username': 'Имя пользователя',
'settings.email': 'Эл. почта',
@@ -168,6 +237,14 @@ const ru: Record = {
'settings.saveProfile': 'Сохранить профиль',
'settings.mfa.title': 'Двухфакторная аутентификация (2FA)',
'settings.mfa.description': 'Добавляет второй шаг при входе. Используйте приложение-аутентификатор (Google Authenticator, Authy и др.).',
+ 'settings.mfa.requiredByPolicy': 'Администратор требует двухфакторную аутентификацию. Настройте приложение-аутентификатор ниже, прежде чем продолжить.',
+ 'settings.mfa.backupTitle': 'Резервные коды',
+ 'settings.mfa.backupDescription': 'Используйте эти одноразовые коды, если потеряете доступ к приложению-аутентификатору.',
+ 'settings.mfa.backupWarning': 'Сохраните их сейчас. Каждый код можно использовать только один раз.',
+ 'settings.mfa.backupCopy': 'Скопировать коды',
+ 'settings.mfa.backupDownload': 'Скачать TXT',
+ 'settings.mfa.backupPrint': 'Печать / PDF',
+ 'settings.mfa.backupCopied': 'Резервные коды скопированы',
'settings.mfa.enabled': '2FA включена для вашего аккаунта.',
'settings.mfa.disabled': '2FA не включена.',
'settings.mfa.setup': 'Настроить аутентификатор',
@@ -271,6 +348,7 @@ const ru: Record = {
'admin.tabs.users': 'Пользователи',
'admin.tabs.categories': 'Категории',
'admin.tabs.backup': 'Резервная копия',
+ 'admin.tabs.audit': 'Журнал аудита',
'admin.stats.users': 'Пользователи',
'admin.stats.trips': 'Поездки',
'admin.stats.places': 'Места',
@@ -320,6 +398,8 @@ const ru: Record = {
'admin.tabs.settings': 'Настройки',
'admin.allowRegistration': 'Разрешить регистрацию',
'admin.allowRegistrationHint': 'Новые пользователи могут регистрироваться самостоятельно',
+ 'admin.requireMfa': 'Требовать двухфакторную аутентификацию (2FA)',
+ 'admin.requireMfaHint': 'Пользователи без 2FA должны завершить настройку в разделе «Настройки» перед использованием приложения.',
'admin.apiKeys': 'API-ключи',
'admin.apiKeysHint': 'Необязательно. Включает расширенные данные о местах, такие как фото и погода.',
'admin.mapsKey': 'API-ключ Google Maps',
@@ -375,6 +455,8 @@ const ru: Record = {
'admin.addons.subtitle': 'Включайте или отключайте функции для настройки TREK под себя.',
'admin.addons.catalog.memories.name': 'Фото (Immich)',
'admin.addons.catalog.memories.description': 'Делитесь фотографиями из поездок через Immich',
+ 'admin.addons.catalog.mcp.name': 'MCP',
+ 'admin.addons.catalog.mcp.description': 'Протокол контекста модели для интеграции с ИИ-ассистентами',
'admin.addons.catalog.packing.name': 'Сборы',
'admin.addons.catalog.packing.description': 'Чек-листы для подготовки багажа к каждой поездке',
'admin.addons.catalog.budget.name': 'Бюджет',
@@ -393,8 +475,10 @@ const ru: Record = {
'admin.addons.disabled': 'Отключено',
'admin.addons.type.trip': 'Поездка',
'admin.addons.type.global': 'Глобально',
+ 'admin.addons.type.integration': 'Интеграция',
'admin.addons.tripHint': 'Доступно как вкладка внутри каждой поездки',
'admin.addons.globalHint': 'Доступно как отдельный раздел в основной навигации',
+ 'admin.addons.integrationHint': 'Фоновые сервисы и API-интеграции без отдельной страницы',
'admin.addons.toast.updated': 'Дополнение обновлено',
'admin.addons.toast.error': 'Не удалось обновить дополнение',
'admin.addons.noAddons': 'Нет доступных дополнений',
@@ -410,8 +494,37 @@ const ru: Record = {
'admin.weather.requestsDesc': 'Бесплатно, API-ключ не требуется',
'admin.weather.locationHint': 'Погода основана на первом месте с координатами в каждом дне. Если ни одно место не назначено на день, в качестве ориентира используется любое место из списка.',
+ // MCP Tokens
+ 'admin.tabs.mcpTokens': 'MCP-токены',
+ 'admin.mcpTokens.title': 'MCP-токены',
+ 'admin.mcpTokens.subtitle': 'Управление API-токенами всех пользователей',
+ 'admin.mcpTokens.owner': 'Владелец',
+ 'admin.mcpTokens.tokenName': 'Название токена',
+ 'admin.mcpTokens.created': 'Создан',
+ 'admin.mcpTokens.lastUsed': 'Последнее использование',
+ 'admin.mcpTokens.never': 'Никогда',
+ 'admin.mcpTokens.empty': 'MCP-токены ещё не созданы',
+ 'admin.mcpTokens.deleteTitle': 'Удалить токен',
+ 'admin.mcpTokens.deleteMessage': 'Токен будет немедленно отозван. Пользователь потеряет доступ к MCP через этот токен.',
+ 'admin.mcpTokens.deleteSuccess': 'Токен удалён',
+ 'admin.mcpTokens.deleteError': 'Не удалось удалить токен',
+ 'admin.mcpTokens.loadError': 'Не удалось загрузить токены',
+
// GitHub
'admin.tabs.github': 'GitHub',
+
+ 'admin.audit.subtitle': 'События, связанные с безопасностью и администрированием (резервные копии, пользователи, MFA, настройки).',
+ 'admin.audit.empty': 'Записей аудита пока нет.',
+ 'admin.audit.refresh': 'Обновить',
+ 'admin.audit.loadMore': 'Загрузить ещё',
+ 'admin.audit.showing': 'Загружено: {count} · всего {total}',
+ 'admin.audit.col.time': 'Время',
+ 'admin.audit.col.user': 'Пользователь',
+ 'admin.audit.col.action': 'Действие',
+ 'admin.audit.col.resource': 'Объект',
+ 'admin.audit.col.ip': 'IP',
+ 'admin.audit.col.details': 'Подробности',
+
'admin.github.title': 'История релизов',
'admin.github.subtitle': 'Последние обновления из {repo}',
'admin.github.latest': 'Последний',
@@ -475,6 +588,14 @@ const ru: Record = {
'vacay.carriedOver': 'из {year}',
'vacay.blockWeekends': 'Блокировать выходные',
'vacay.blockWeekendsHint': 'Запретить записи об отпуске в субботу и воскресенье',
+ 'vacay.weekendDays': 'Выходные дни',
+ 'vacay.mon': 'Пн',
+ 'vacay.tue': 'Вт',
+ 'vacay.wed': 'Ср',
+ 'vacay.thu': 'Чт',
+ 'vacay.fri': 'Пт',
+ 'vacay.sat': 'Сб',
+ 'vacay.sun': 'Вс',
'vacay.publicHolidays': 'Государственные праздники',
'vacay.publicHolidaysHint': 'Отмечать государственные праздники в календаре',
'vacay.selectCountry': 'Выберите страну',
@@ -569,6 +690,10 @@ const ru: Record = {
'atlas.markVisited': 'Отметить как посещённую',
'atlas.markVisitedHint': 'Добавить эту страну в список посещённых',
'atlas.addToBucket': 'В список желаний',
+ 'atlas.addPoi': 'Добавить место',
+ 'atlas.bucketNamePlaceholder': 'Название (страна, город, место…)',
+ 'atlas.month': 'Месяц',
+ 'atlas.year': 'Год',
'atlas.addToBucketHint': 'Сохранить как место для посещения',
'atlas.bucketWhen': 'Когда вы планируете поехать?',
@@ -618,14 +743,26 @@ const ru: Record = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Экспортировать план дня в PDF',
'dayplan.pdfError': 'Ошибка экспорта PDF',
+ 'dayplan.cannotReorderTransport': 'Бронирования с фиксированным временем нельзя перемещать',
+ 'dayplan.confirmRemoveTimeTitle': 'Удалить время?',
+ 'dayplan.confirmRemoveTimeBody': 'У этого места фиксированное время ({time}). При перемещении время будет удалено, и станет доступна свободная сортировка.',
+ 'dayplan.confirmRemoveTimeAction': 'Удалить время и переместить',
+ 'dayplan.cannotDropOnTimed': 'Элементы нельзя размещать между записями с фиксированным временем',
+ 'dayplan.cannotBreakChronology': 'Это нарушит хронологический порядок запланированных элементов и бронирований',
// Places Sidebar
'places.addPlace': 'Добавить место/активность',
+ 'places.importGpx': 'Импорт GPX',
+ 'places.gpxImported': '{count} мест импортировано из GPX',
+ 'places.gpxError': 'Ошибка импорта GPX',
+ 'places.urlResolved': 'Место импортировано из URL',
'places.assignToDay': 'Добавить в какой день?',
'places.all': 'Все',
'places.unplanned': 'Незапланированные',
'places.search': 'Поиск мест...',
'places.allCategories': 'Все категории',
+ 'places.categoriesSelected': 'категорий',
+ 'places.clearFilter': 'Сбросить фильтр',
'places.count': '{count} мест',
'places.countSingular': '1 место',
'places.allPlanned': 'Все места запланированы',
@@ -724,6 +861,8 @@ const ru: Record = {
'reservations.type.tour': 'Экскурсия',
'reservations.type.other': 'Другое',
'reservations.confirm.delete': 'Вы уверены, что хотите удалить бронирование «{name}»?',
+ 'reservations.confirm.deleteTitle': 'Удалить бронирование?',
+ 'reservations.confirm.deleteBody': '«{name}» будет удалено навсегда.',
'reservations.toast.updated': 'Бронирование обновлено',
'reservations.toast.removed': 'Бронирование удалено',
'reservations.toast.fileUploaded': 'Файл загружен',
@@ -781,6 +920,9 @@ const ru: Record = {
'budget.paid': 'Оплачено',
'budget.open': 'Не оплачено',
'budget.noMembers': 'Участники не назначены',
+ 'budget.settlement': 'Взаиморасчёт',
+ 'budget.settlementInfo': 'Нажмите на аватар участника в строке бюджета, чтобы отметить его зелёным — это значит, что он заплатил. Взаиморасчёт покажет, кто кому и сколько должен.',
+ 'budget.netBalances': 'Чистые балансы',
// Files
'files.title': 'Файлы',
@@ -834,6 +976,15 @@ const ru: Record = {
// Packing
'packing.title': 'Список вещей',
'packing.empty': 'Список вещей пуст',
+ 'packing.import': 'Импорт',
+ 'packing.importTitle': 'Импорт списка вещей',
+ 'packing.importHint': 'Один предмет на строку. Категория и количество — через запятую, точку с запятой или табуляцию: Название, Категория, Количество',
+ 'packing.importPlaceholder': 'Зубная щётка\nСолнцезащитный крем, Гигиена\nФутболки, Одежда, 5\nПаспорт, Документы',
+ 'packing.importCsv': 'Загрузить CSV/TXT',
+ 'packing.importAction': 'Импортировать {count}',
+ 'packing.importSuccess': '{count} предметов импортировано',
+ 'packing.importError': 'Ошибка импорта',
+ 'packing.importEmpty': 'Нет предметов для импорта',
'packing.progress': '{packed} из {total} собрано ({percent}%)',
'packing.clearChecked': 'Удалить {count} отмеченных',
'packing.clearCheckedShort': 'Удалить {count}',
@@ -985,7 +1136,27 @@ const ru: Record = {
'backup.auto.enable': 'Включить автокопирование',
'backup.auto.enableHint': 'Резервные копии будут создаваться автоматически по выбранному расписанию',
'backup.auto.interval': 'Интервал',
+ 'backup.auto.hour': 'Запуск в час',
+ 'backup.auto.hourHint': 'Местное время сервера (формат {format})',
+ 'backup.auto.dayOfWeek': 'День недели',
+ 'backup.auto.dayOfMonth': 'День месяца',
+ 'backup.auto.dayOfMonthHint': 'Ограничено 1–28 для совместимости со всеми месяцами',
+ 'backup.auto.scheduleSummary': 'Расписание',
+ 'backup.auto.summaryDaily': 'Каждый день в {hour}:00',
+ 'backup.auto.summaryWeekly': 'Каждый {day} в {hour}:00',
+ 'backup.auto.summaryMonthly': '{day}-го числа каждого месяца в {hour}:00',
+ 'backup.auto.envLocked': 'Docker',
+ 'backup.auto.envLockedHint': 'Автокопирование настроено через переменные окружения Docker. Чтобы изменить параметры, обновите docker-compose.yml и перезапустите контейнер.',
+ 'backup.auto.copyEnv': 'Скопировать переменные окружения Docker',
+ 'backup.auto.envCopied': 'Переменные окружения Docker скопированы в буфер обмена',
'backup.auto.keepLabel': 'Удалять старые копии через',
+ 'backup.dow.sunday': 'Вс',
+ 'backup.dow.monday': 'Пн',
+ 'backup.dow.tuesday': 'Вт',
+ 'backup.dow.wednesday': 'Ср',
+ 'backup.dow.thursday': 'Чт',
+ 'backup.dow.friday': 'Пт',
+ 'backup.dow.saturday': 'Сб',
'backup.interval.hourly': 'Каждый час',
'backup.interval.daily': 'Ежедневно',
'backup.interval.weekly': 'Еженедельно',
@@ -1137,6 +1308,19 @@ const ru: Record = {
'memories.oldest': 'Сначала старые',
'memories.newest': 'Сначала новые',
'memories.allLocations': 'Все места',
+ 'memories.addPhotos': 'Добавить фото',
+ 'memories.selectPhotos': 'Выбрать фото из Immich',
+ 'memories.selectHint': 'Нажмите на фото, чтобы выбрать их.',
+ 'memories.selected': 'выбрано',
+ 'memories.addSelected': 'Добавить {count} фото',
+ 'memories.alreadyAdded': 'Добавлено',
+ 'memories.private': 'Приватное',
+ 'memories.stopSharing': 'Прекратить доступ',
+ 'memories.tripDates': 'Даты поездки',
+ 'memories.allPhotos': 'Все фото',
+ 'memories.confirmShareTitle': 'Поделиться с участниками поездки?',
+ 'memories.confirmShareHint': '{count} фото станут видны всем участникам этой поездки. Вы сможете сделать отдельные фото приватными позже.',
+ 'memories.confirmShareButton': 'Поделиться фото',
// Collab Addon
'collab.tabs.chat': 'Чат',
diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts
index 6da7988..65c0540 100644
--- a/client/src/i18n/translations/zh.ts
+++ b/client/src/i18n/translations/zh.ts
@@ -139,8 +139,77 @@ const zh: Record = {
'settings.temperature': '温度单位',
'settings.timeFormat': '时间格式',
'settings.routeCalculation': '路线计算',
+ 'settings.blurBookingCodes': '模糊预订代码',
+ 'settings.notifications': '通知',
+ 'settings.notifyTripInvite': '旅行邀请',
+ 'settings.notifyBookingChange': '预订变更',
+ 'settings.notifyTripReminder': '旅行提醒',
+ 'settings.notifyVacayInvite': 'Vacay 融合邀请',
+ 'settings.notifyPhotosShared': '共享照片 (Immich)',
+ 'settings.notifyCollabMessage': '聊天消息 (Collab)',
+ 'settings.notifyPackingTagged': '行李清单:分配',
+ 'settings.notifyWebhook': 'Webhook 通知',
+ 'admin.smtp.title': '邮件与通知',
+ 'admin.smtp.hint': '用于邮件通知的 SMTP 配置。可选:Discord、Slack 等的 Webhook URL。',
+ 'admin.smtp.testButton': '发送测试邮件',
+ 'admin.smtp.testSuccess': '测试邮件发送成功',
+ 'admin.smtp.testFailed': '测试邮件发送失败',
+ 'dayplan.icsTooltip': '导出日历 (ICS)',
+ 'share.linkTitle': '公开链接',
+ 'share.linkHint': '创建一个链接,任何人无需登录即可查看此旅行。仅可查看,无法编辑。',
+ 'share.createLink': '创建链接',
+ 'share.deleteLink': '删除链接',
+ 'share.createError': '无法创建链接',
+ 'common.copy': '复制',
+ 'common.copied': '已复制',
+ 'share.permMap': '地图与计划',
+ 'share.permBookings': '预订',
+ 'share.permPacking': '行李',
+ 'shared.expired': '链接已过期或无效',
+ 'shared.expiredHint': '此共享旅行链接已失效。',
+ 'shared.readOnly': '只读共享视图',
+ 'shared.tabPlan': '计划',
+ 'shared.tabBookings': '预订',
+ 'shared.tabPacking': '行李',
+ 'shared.tabBudget': '预算',
+ 'shared.tabChat': '聊天',
+ 'shared.days': '天',
+ 'shared.places': '个地点',
+ 'shared.other': '其他',
+ 'shared.totalBudget': '总预算',
+ 'shared.messages': '条消息',
+ 'shared.sharedVia': '通过以下分享',
+ 'shared.confirmed': '已确认',
+ 'shared.pending': '待确认',
+ 'share.permBudget': '预算',
+ 'share.permCollab': '聊天',
'settings.on': '开',
'settings.off': '关',
+ 'settings.mcp.title': 'MCP 配置',
+ 'settings.mcp.endpoint': 'MCP 端点',
+ 'settings.mcp.clientConfig': '客户端配置',
+ 'settings.mcp.clientConfigHint': '将 替换为下方列表中的 API 令牌。npx 的路径可能需要根据您的系统进行调整(例如 Windows 上为 C:\\PROGRA~1\\nodejs\\npx.cmd)。',
+ 'settings.mcp.copy': '复制',
+ 'settings.mcp.copied': '已复制!',
+ 'settings.mcp.apiTokens': 'API 令牌',
+ 'settings.mcp.createToken': '创建新令牌',
+ 'settings.mcp.noTokens': '暂无令牌,请创建一个以连接 MCP 客户端。',
+ 'settings.mcp.tokenCreatedAt': '创建于',
+ 'settings.mcp.tokenUsedAt': '使用于',
+ 'settings.mcp.deleteTokenTitle': '删除令牌',
+ 'settings.mcp.deleteTokenMessage': '此令牌将立即失效,使用它的所有 MCP 客户端将失去访问权限。',
+ 'settings.mcp.modal.createTitle': '创建 API 令牌',
+ 'settings.mcp.modal.tokenName': '令牌名称',
+ 'settings.mcp.modal.tokenNamePlaceholder': '例如:Claude Desktop、工作电脑',
+ 'settings.mcp.modal.creating': '创建中…',
+ 'settings.mcp.modal.create': '创建令牌',
+ 'settings.mcp.modal.createdTitle': '令牌已创建',
+ 'settings.mcp.modal.createdWarning': '此令牌只会显示一次,请立即复制并妥善保存——无法找回。',
+ 'settings.mcp.modal.done': '完成',
+ 'settings.mcp.toast.created': '令牌已创建',
+ 'settings.mcp.toast.createError': '创建令牌失败',
+ 'settings.mcp.toast.deleted': '令牌已删除',
+ 'settings.mcp.toast.deleteError': '删除令牌失败',
'settings.account': '账户',
'settings.username': '用户名',
'settings.email': '邮箱',
@@ -168,6 +237,14 @@ const zh: Record = {
'settings.saveProfile': '保存资料',
'settings.mfa.title': '双因素认证 (2FA)',
'settings.mfa.description': '登录时添加第二步验证。使用身份验证器应用(Google Authenticator、Authy 等)。',
+ 'settings.mfa.requiredByPolicy': '管理员要求双因素身份验证。请先完成下方的身份验证器设置后再继续。',
+ 'settings.mfa.backupTitle': '备用代码',
+ 'settings.mfa.backupDescription': '如果你无法使用身份验证器应用,可使用这些一次性备用代码登录。',
+ 'settings.mfa.backupWarning': '请立即保存这些代码。每个代码只能使用一次。',
+ 'settings.mfa.backupCopy': '复制代码',
+ 'settings.mfa.backupDownload': '下载 TXT',
+ 'settings.mfa.backupPrint': '打印 / PDF',
+ 'settings.mfa.backupCopied': '备用代码已复制',
'settings.mfa.enabled': '您的账户已启用 2FA。',
'settings.mfa.disabled': '2FA 未启用。',
'settings.mfa.setup': '设置身份验证器',
@@ -271,6 +348,7 @@ const zh: Record = {
'admin.tabs.users': '用户',
'admin.tabs.categories': '分类',
'admin.tabs.backup': '备份',
+ 'admin.tabs.audit': '审计日志',
'admin.stats.users': '用户',
'admin.stats.trips': '旅行',
'admin.stats.places': '地点',
@@ -320,6 +398,8 @@ const zh: Record = {
'admin.tabs.settings': '设置',
'admin.allowRegistration': '允许注册',
'admin.allowRegistrationHint': '新用户可以自行注册',
+ 'admin.requireMfa': '要求双因素身份验证(2FA)',
+ 'admin.requireMfaHint': '未启用 2FA 的用户必须先完成设置中的配置才能使用应用。',
'admin.apiKeys': 'API 密钥',
'admin.apiKeysHint': '可选。启用地点的扩展数据,如照片和天气。',
'admin.mapsKey': 'Google Maps API 密钥',
@@ -375,6 +455,8 @@ const zh: Record = {
'admin.addons.subtitle': '启用或禁用功能以自定义你的 TREK 体验。',
'admin.addons.catalog.memories.name': '照片 (Immich)',
'admin.addons.catalog.memories.description': '通过 Immich 实例分享旅行照片',
+ 'admin.addons.catalog.mcp.name': 'MCP',
+ 'admin.addons.catalog.mcp.description': '用于 AI 助手集成的模型上下文协议',
'admin.addons.catalog.packing.name': '行李',
'admin.addons.catalog.packing.description': '每次旅行的行李准备清单',
'admin.addons.catalog.budget.name': '预算',
@@ -393,8 +475,10 @@ const zh: Record = {
'admin.addons.disabled': '已禁用',
'admin.addons.type.trip': '旅行',
'admin.addons.type.global': '全局',
+ 'admin.addons.type.integration': '集成',
'admin.addons.tripHint': '在每次旅行中作为标签页显示',
'admin.addons.globalHint': '在主导航中作为独立板块显示',
+ 'admin.addons.integrationHint': '后端服务和 API 集成,无专属页面',
'admin.addons.toast.updated': '扩展已更新',
'admin.addons.toast.error': '更新扩展失败',
'admin.addons.noAddons': '暂无可用扩展',
@@ -410,8 +494,37 @@ const zh: Record = {
'admin.weather.requestsDesc': '免费,无需 API 密钥',
'admin.weather.locationHint': '天气基于每天中第一个有坐标的地点。如果当天没有分配地点,则使用地点列表中的任意地点作为参考。',
+ // MCP Tokens
+ 'admin.tabs.mcpTokens': 'MCP 令牌',
+ 'admin.mcpTokens.title': 'MCP 令牌',
+ 'admin.mcpTokens.subtitle': '管理所有用户的 API 令牌',
+ 'admin.mcpTokens.owner': '所有者',
+ 'admin.mcpTokens.tokenName': '令牌名称',
+ 'admin.mcpTokens.created': '创建时间',
+ 'admin.mcpTokens.lastUsed': '最后使用',
+ 'admin.mcpTokens.never': '从未',
+ 'admin.mcpTokens.empty': '尚未创建任何 MCP 令牌',
+ 'admin.mcpTokens.deleteTitle': '删除令牌',
+ 'admin.mcpTokens.deleteMessage': '此令牌将立即被撤销。用户将失去通过此令牌的 MCP 访问权限。',
+ 'admin.mcpTokens.deleteSuccess': '令牌已删除',
+ 'admin.mcpTokens.deleteError': '删除令牌失败',
+ 'admin.mcpTokens.loadError': '加载令牌失败',
+
// GitHub
'admin.tabs.github': 'GitHub',
+
+ 'admin.audit.subtitle': '安全与管理员操作记录(备份、用户、MFA、设置)。',
+ 'admin.audit.empty': '暂无审计记录。',
+ 'admin.audit.refresh': '刷新',
+ 'admin.audit.loadMore': '加载更多',
+ 'admin.audit.showing': '已加载 {count} 条 · 共 {total} 条',
+ 'admin.audit.col.time': '时间',
+ 'admin.audit.col.user': '用户',
+ 'admin.audit.col.action': '操作',
+ 'admin.audit.col.resource': '资源',
+ 'admin.audit.col.ip': 'IP',
+ 'admin.audit.col.details': '详情',
+
'admin.github.title': '版本历史',
'admin.github.subtitle': '{repo} 的最新更新',
'admin.github.latest': '最新',
@@ -475,6 +588,14 @@ const zh: Record = {
'vacay.carriedOver': '从 {year} 结转',
'vacay.blockWeekends': '锁定周末',
'vacay.blockWeekendsHint': '禁止在周六和周日安排假期',
+ 'vacay.weekendDays': '周末',
+ 'vacay.mon': '周一',
+ 'vacay.tue': '周二',
+ 'vacay.wed': '周三',
+ 'vacay.thu': '周四',
+ 'vacay.fri': '周五',
+ 'vacay.sat': '周六',
+ 'vacay.sun': '周日',
'vacay.publicHolidays': '公共假日',
'vacay.publicHolidaysHint': '在日历中标记公共假日',
'vacay.selectCountry': '选择国家',
@@ -569,6 +690,10 @@ const zh: Record = {
'atlas.markVisited': '标记为已访问',
'atlas.markVisitedHint': '将此国家添加到已访问列表',
'atlas.addToBucket': '添加到心愿单',
+ 'atlas.addPoi': '添加地点',
+ 'atlas.bucketNamePlaceholder': '名称(国家、城市、地点…)',
+ 'atlas.month': '月份',
+ 'atlas.year': '年份',
'atlas.addToBucketHint': '保存为想去的地方',
'atlas.bucketWhen': '你计划什么时候去?',
@@ -618,14 +743,26 @@ const zh: Record = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': '导出当天计划为 PDF',
'dayplan.pdfError': 'PDF 导出失败',
+ 'dayplan.cannotReorderTransport': '有固定时间的预订无法重新排序',
+ 'dayplan.confirmRemoveTimeTitle': '移除时间?',
+ 'dayplan.confirmRemoveTimeBody': '此地点有固定时间({time})。移动后将移除时间并允许自由排序。',
+ 'dayplan.confirmRemoveTimeAction': '移除时间并移动',
+ 'dayplan.cannotDropOnTimed': '无法将项目放置在有固定时间的条目之间',
+ 'dayplan.cannotBreakChronology': '这将打乱已计划项目和预订的时间顺序',
// Places Sidebar
'places.addPlace': '添加地点/活动',
+ 'places.importGpx': '导入 GPX',
+ 'places.gpxImported': '已从 GPX 导入 {count} 个地点',
+ 'places.gpxError': 'GPX 导入失败',
+ 'places.urlResolved': '已从 URL 导入地点',
'places.assignToDay': '添加到哪一天?',
'places.all': '全部',
'places.unplanned': '未规划',
'places.search': '搜索地点...',
'places.allCategories': '所有分类',
+ 'places.categoriesSelected': '个分类',
+ 'places.clearFilter': '清除筛选',
'places.count': '{count} 个地点',
'places.countSingular': '1 个地点',
'places.allPlanned': '所有地点已规划',
@@ -724,6 +861,8 @@ const zh: Record = {
'reservations.type.tour': '旅游团',
'reservations.type.other': '其他',
'reservations.confirm.delete': '确定要删除预订「{name}」吗?',
+ 'reservations.confirm.deleteTitle': '删除预订?',
+ 'reservations.confirm.deleteBody': '"{name}" 将被永久删除。',
'reservations.toast.updated': '预订已更新',
'reservations.toast.removed': '预订已删除',
'reservations.toast.fileUploaded': '文件已上传',
@@ -781,6 +920,9 @@ const zh: Record = {
'budget.paid': '已支付',
'budget.open': '未支付',
'budget.noMembers': '未分配成员',
+ 'budget.settlement': '结算',
+ 'budget.settlementInfo': '点击预算项目上的成员头像将其标记为绿色——表示该成员已付款。结算会显示谁欠谁多少。',
+ 'budget.netBalances': '净余额',
// Files
'files.title': '文件',
@@ -834,6 +976,15 @@ const zh: Record = {
// Packing
'packing.title': '行李清单',
'packing.empty': '行李清单为空',
+ 'packing.import': '导入',
+ 'packing.importTitle': '导入装箱清单',
+ 'packing.importHint': '每行一个物品。可选用逗号、分号或制表符分隔类别和数量:名称, 类别, 数量',
+ 'packing.importPlaceholder': '牙刷\n防晒霜, 卫生\nT恤, 衣物, 5\n护照, 证件',
+ 'packing.importCsv': '加载 CSV/TXT',
+ 'packing.importAction': '导入 {count}',
+ 'packing.importSuccess': '已导入 {count} 项',
+ 'packing.importError': '导入失败',
+ 'packing.importEmpty': '没有可导入的项目',
'packing.progress': '已打包 {packed}/{total}({percent}%)',
'packing.clearChecked': '移除 {count} 个已勾选',
'packing.clearCheckedShort': '移除 {count} 个',
@@ -985,7 +1136,27 @@ const zh: Record = {
'backup.auto.enable': '启用自动备份',
'backup.auto.enableHint': '将按所选计划自动创建备份',
'backup.auto.interval': '间隔',
+ 'backup.auto.hour': '执行时间',
+ 'backup.auto.hourHint': '服务器本地时间({format} 格式)',
+ 'backup.auto.dayOfWeek': '星期几',
+ 'backup.auto.dayOfMonth': '每月几号',
+ 'backup.auto.dayOfMonthHint': '限 1–28 以兼容所有月份',
+ 'backup.auto.scheduleSummary': '计划',
+ 'backup.auto.summaryDaily': '每天 {hour}:00',
+ 'backup.auto.summaryWeekly': '每{day} {hour}:00',
+ 'backup.auto.summaryMonthly': '每月 {day} 号 {hour}:00',
+ 'backup.auto.envLocked': 'Docker',
+ 'backup.auto.envLockedHint': '自动备份通过 Docker 环境变量配置。要更改设置,请更新 docker-compose.yml 并重启容器。',
+ 'backup.auto.copyEnv': '复制 Docker 环境变量',
+ 'backup.auto.envCopied': 'Docker 环境变量已复制到剪贴板',
'backup.auto.keepLabel': '自动删除旧备份',
+ 'backup.dow.sunday': '周日',
+ 'backup.dow.monday': '周一',
+ 'backup.dow.tuesday': '周二',
+ 'backup.dow.wednesday': '周三',
+ 'backup.dow.thursday': '周四',
+ 'backup.dow.friday': '周五',
+ 'backup.dow.saturday': '周六',
'backup.interval.hourly': '每小时',
'backup.interval.daily': '每天',
'backup.interval.weekly': '每周',
@@ -1137,6 +1308,19 @@ const zh: Record = {
'memories.oldest': '最早优先',
'memories.newest': '最新优先',
'memories.allLocations': '所有地点',
+ 'memories.addPhotos': '添加照片',
+ 'memories.selectPhotos': '从 Immich 选择照片',
+ 'memories.selectHint': '点击照片以选择。',
+ 'memories.selected': '已选择',
+ 'memories.addSelected': '添加 {count} 张照片',
+ 'memories.alreadyAdded': '已添加',
+ 'memories.private': '私密',
+ 'memories.stopSharing': '停止分享',
+ 'memories.tripDates': '旅行日期',
+ 'memories.allPhotos': '所有照片',
+ 'memories.confirmShareTitle': '与旅行成员分享?',
+ 'memories.confirmShareHint': '{count} 张照片将对本次旅行的所有成员可见。你可以稍后将单张照片设为私密。',
+ 'memories.confirmShareButton': '分享照片',
// Collab Addon
'collab.tabs.chat': '聊天',
diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx
index b63306f..e05191e 100644
--- a/client/src/pages/AdminPage.tsx
+++ b/client/src/pages/AdminPage.tsx
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
-import { adminApi, authApi } from '../api/client'
+import apiClient, { adminApi, authApi, notificationsApi } from '../api/client'
import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { useTranslation } from '../i18n'
@@ -13,6 +13,8 @@ import BackupPanel from '../components/Admin/BackupPanel'
import GitHubPanel from '../components/Admin/GitHubPanel'
import AddonManager from '../components/Admin/AddonManager'
import PackingTemplateManager from '../components/Admin/PackingTemplateManager'
+import AuditLogPanel from '../components/Admin/AuditLogPanel'
+import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel'
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun, Link2, Copy, Plus } from 'lucide-react'
import CustomSelect from '../components/shared/CustomSelect'
@@ -52,7 +54,7 @@ interface UpdateInfo {
}
export default function AdminPage(): React.ReactElement {
- const { demoMode } = useAuthStore()
+ const { demoMode, serverTimezone } = useAuthStore()
const { t, locale } = useTranslation()
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
const TABS = [
@@ -61,6 +63,8 @@ export default function AdminPage(): React.ReactElement {
{ id: 'addons', label: t('admin.tabs.addons') },
{ id: 'settings', label: t('admin.tabs.settings') },
{ id: 'backup', label: t('admin.tabs.backup') },
+ { id: 'audit', label: t('admin.tabs.audit') },
+ { id: 'mcp-tokens', label: t('admin.tabs.mcpTokens') },
{ id: 'github', label: t('admin.tabs.github') },
]
@@ -83,6 +87,7 @@ export default function AdminPage(): React.ReactElement {
// Registration toggle
const [allowRegistration, setAllowRegistration] = useState(true)
+ const [requireMfa, setRequireMfa] = useState(false)
// Invite links
const [invites, setInvites] = useState([])
@@ -93,6 +98,16 @@ export default function AdminPage(): React.ReactElement {
const [allowedFileTypes, setAllowedFileTypes] = useState('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv')
const [savingFileTypes, setSavingFileTypes] = useState(false)
+ // SMTP settings
+ const [smtpValues, setSmtpValues] = useState>({})
+ const [smtpLoaded, setSmtpLoaded] = useState(false)
+ useEffect(() => {
+ apiClient.get('/auth/app-settings').then(r => {
+ setSmtpValues(r.data || {})
+ setSmtpLoaded(true)
+ }).catch(() => setSmtpLoaded(true))
+ }, [])
+
// API Keys
const [mapsKey, setMapsKey] = useState('')
const [weatherKey, setWeatherKey] = useState('')
@@ -107,7 +122,7 @@ export default function AdminPage(): React.ReactElement {
const [updating, setUpdating] = useState(false)
const [updateResult, setUpdateResult] = useState<'success' | 'error' | null>(null)
- const { user: currentUser, updateApiKeys } = useAuthStore()
+ const { user: currentUser, updateApiKeys, setAppRequireMfa } = useAuthStore()
const navigate = useNavigate()
const toast = useToast()
@@ -143,6 +158,7 @@ export default function AdminPage(): React.ReactElement {
try {
const config = await authApi.getAppConfig()
setAllowRegistration(config.allow_registration)
+ if (config.require_mfa !== undefined) setRequireMfa(!!config.require_mfa)
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
} catch (err: unknown) {
// ignore
@@ -189,6 +205,18 @@ export default function AdminPage(): React.ReactElement {
}
}
+ const handleToggleRequireMfa = async (value: boolean) => {
+ setRequireMfa(value)
+ try {
+ await authApi.updateAppSettings({ require_mfa: value })
+ setAppRequireMfa(value)
+ toast.success(t('common.saved'))
+ } catch (err: unknown) {
+ setRequireMfa(!value)
+ toast.error(getApiErrorMessage(err, t('common.error')))
+ }
+ }
+
const toggleKey = (key) => {
setShowKeys(prev => ({ ...prev, [key]: !prev[key] }))
}
@@ -333,7 +361,7 @@ export default function AdminPage(): React.ReactElement {
-
Administration
+
{t('admin.title')}
{t('admin.subtitle')}
@@ -512,10 +540,10 @@ export default function AdminPage(): React.ReactElement {
- {new Date(u.created_at).toLocaleDateString(locale)}
+ {new Date(u.created_at).toLocaleDateString(locale, { timeZone: serverTimezone })}
|
- {u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'}
+ {u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12, timeZone: serverTimezone }) : '—'}
|
@@ -584,7 +612,7 @@ export default function AdminPage(): React.ReactElement {
{inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')}
- {inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale)}`}
+ {inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale, { timeZone: serverTimezone })}`}
{` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
@@ -694,6 +722,34 @@ export default function AdminPage(): React.ReactElement {
+ {/* Require 2FA for all users */}
+
+
+ {t('admin.requireMfa')}
+
+
+
+
+ {t('admin.requireMfa')}
+ {t('admin.requireMfaHint')}
+
+
+
+
+
+
{/* Allowed File Types */}
@@ -918,11 +974,75 @@ export default function AdminPage(): React.ReactElement {
+ {/* SMTP / Notifications */}
+
+
+ {t('admin.smtp.title')}
+ {t('admin.smtp.hint')}
+
+
+ {smtpLoaded && [
+ { key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' },
+ { key: 'smtp_port', label: 'SMTP Port', placeholder: '587' },
+ { key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' },
+ { key: 'smtp_pass', label: 'SMTP Password', placeholder: '••••••••', type: 'password' },
+ { key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' },
+ { key: 'notification_webhook_url', label: 'Webhook URL (optional)', placeholder: 'https://discord.com/api/webhooks/...' },
+ { key: 'app_url', label: 'App URL (for email links)', placeholder: 'https://trek.example.com' },
+ ].map(field => (
+
+
+ setSmtpValues(prev => ({ ...prev, [field.key]: e.target.value }))}
+ placeholder={field.placeholder}
+ onBlur={e => { if (e.target.value !== '') authApi.updateAppSettings({ [field.key]: e.target.value }).then(() => toast.success(t('common.saved'))).catch(() => {}) }}
+ className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
+ />
+
+ ))}
+ {/* Skip TLS toggle */}
+
+
+ Skip TLS certificate check
+ Enable for self-signed certificates on local mail servers
+
+
+
+
+
+
)}
{activeTab === 'backup' && }
+ {activeTab === 'audit' && }
+
+ {activeTab === 'mcp-tokens' && }
+
{activeTab === 'github' && }
diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx
index 59723d2..1129667 100644
--- a/client/src/pages/AtlasPage.tsx
+++ b/client/src/pages/AtlasPage.tsx
@@ -3,9 +3,9 @@ import { useNavigate } from 'react-router-dom'
import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../i18n'
import { useSettingsStore } from '../store/settingsStore'
import Navbar from '../components/Layout/Navbar'
-import apiClient from '../api/client'
+import apiClient, { mapsApi } from '../api/client'
import CustomSelect from '../components/shared/CustomSelect'
-import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2 } from 'lucide-react'
+import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react'
import L from 'leaflet'
import type { AtlasPlace, GeoJsonFeatureCollection, TranslationFn } from '../types'
@@ -154,14 +154,19 @@ export default function AtlasPage(): React.ReactElement {
const [countryDetail, setCountryDetail] = useState(null)
const [geoData, setGeoData] = useState(null)
const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket'; code: string; name: string } | null>(null)
- const [bucketMonth, setBucketMonth] = useState(new Date().getMonth() + 1)
- const [bucketYear, setBucketYear] = useState(new Date().getFullYear())
+ const [bucketMonth, setBucketMonth] = useState(0)
+ const [bucketYear, setBucketYear] = useState(0)
// Bucket list
- interface BucketItem { id: number; name: string; lat: number | null; lng: number | null; country_code: string | null; notes: string | null }
+ interface BucketItem { id: number; name: string; lat: number | null; lng: number | null; country_code: string | null; notes: string | null; target_date: string | null }
const [bucketList, setBucketList] = useState([])
const [showBucketAdd, setShowBucketAdd] = useState(false)
- const [bucketForm, setBucketForm] = useState({ name: '', notes: '' })
+ const [bucketForm, setBucketForm] = useState({ name: '', notes: '', lat: '', lng: '', target_date: '' })
+ const [bucketSearch, setBucketSearch] = useState('')
+ const [bucketSearchResults, setBucketSearchResults] = useState([])
+ const [bucketSearching, setBucketSearching] = useState(false)
+ const [bucketPoiMonth, setBucketPoiMonth] = useState(0)
+ const [bucketPoiYear, setBucketPoiYear] = useState(0)
const [bucketTab, setBucketTab] = useState<'stats' | 'bucket'>('stats')
const bucketMarkersRef = useRef(null)
@@ -179,7 +184,7 @@ export default function AtlasPage(): React.ReactElement {
// Load GeoJSON world data (direct GeoJSON, no conversion needed)
useEffect(() => {
- fetch('https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_admin_0_countries.geojson')
+ fetch('https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson')
.then(r => r.json())
.then(geo => {
// Dynamically build A2→A3 mapping from GeoJSON
@@ -397,9 +402,15 @@ export default function AtlasPage(): React.ReactElement {
const handleAddBucketItem = async (): Promise => {
if (!bucketForm.name.trim()) return
try {
- const r = await apiClient.post('/addons/atlas/bucket-list', { name: bucketForm.name.trim(), notes: bucketForm.notes.trim() || null })
+ const data: Record = { name: bucketForm.name.trim() }
+ if (bucketForm.notes.trim()) data.notes = bucketForm.notes.trim()
+ if (bucketForm.lat && bucketForm.lng) { data.lat = parseFloat(bucketForm.lat); data.lng = parseFloat(bucketForm.lng) }
+ const targetDate = bucketForm.target_date || (bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null)
+ if (targetDate) data.target_date = targetDate
+ const r = await apiClient.post('/addons/atlas/bucket-list', data)
setBucketList(prev => [r.data.item, ...prev])
- setBucketForm({ name: '', notes: '' })
+ setBucketForm({ name: '', notes: '', lat: '', lng: '', target_date: '' })
+ setBucketSearch(''); setBucketSearchResults([]); setBucketPoiMonth(0); setBucketPoiYear(0)
setShowBucketAdd(false)
} catch { /* */ }
}
@@ -411,6 +422,28 @@ export default function AtlasPage(): React.ReactElement {
} catch { /* */ }
}
+ const handleBucketPoiSearch = async () => {
+ if (!bucketSearch.trim()) return
+ setBucketSearching(true)
+ try {
+ const result = await mapsApi.search(bucketSearch, language)
+ setBucketSearchResults(result.places || [])
+ } catch {} finally { setBucketSearching(false) }
+ }
+
+ const handleSelectBucketPoi = (result: any) => {
+ const targetDate = bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null
+ setBucketForm({
+ name: result.name || bucketSearch,
+ notes: '',
+ lat: String(result.lat || ''),
+ lng: String(result.lng || ''),
+ target_date: targetDate || '',
+ })
+ setBucketSearchResults([])
+ setBucketSearch('')
+ }
+
// Render bucket list markers on map
useEffect(() => {
if (!mapInstance.current) return
@@ -517,6 +550,10 @@ export default function AtlasPage(): React.ReactElement {
showBucketAdd={showBucketAdd} setShowBucketAdd={setShowBucketAdd}
bucketForm={bucketForm} setBucketForm={setBucketForm}
onAddBucket={handleAddBucketItem} onDeleteBucket={handleDeleteBucketItem}
+ onSearchBucket={handleBucketPoiSearch} onSelectBucketPoi={handleSelectBucketPoi}
+ bucketSearchResults={bucketSearchResults} bucketPoiMonth={bucketPoiMonth} setBucketPoiMonth={setBucketPoiMonth}
+ bucketPoiYear={bucketPoiYear} setBucketPoiYear={setBucketPoiYear} bucketSearching={bucketSearching}
+ bucketSearch={bucketSearch} setBucketSearch={setBucketSearch}
t={t} dark={dark}
/>
@@ -594,7 +631,11 @@ export default function AtlasPage(): React.ReactElement {
setBucketMonth(Number(v))}
- options={Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleString(language, { month: 'long' }) }))}
+ placeholder={t('atlas.month')}
+ options={[
+ { value: 0, label: '—' },
+ ...Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleString(language, { month: 'long' }) })),
+ ]}
size="sm"
/>
@@ -602,22 +643,27 @@ export default function AtlasPage(): React.ReactElement {
setBucketYear(Number(v))}
- options={Array.from({ length: 20 }, (_, i) => ({ value: new Date().getFullYear() + i, label: String(new Date().getFullYear() + i) }))}
+ placeholder={t('atlas.year')}
+ options={[
+ { value: 0, label: '—' },
+ ...Array.from({ length: 20 }, (_, i) => ({ value: new Date().getFullYear() + i, label: String(new Date().getFullYear() + i) })),
+ ]}
size="sm"
/>
-
+
)}
-
) => setMfaCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
- placeholder="000000"
+ onChange={(e: React.ChangeEvent ) => setMfaCode(e.target.value.toUpperCase().slice(0, 24))}
+ placeholder="000000 or XXXX-XXXX"
required
style={inputBase}
onFocus={(e: React.FocusEvent) => e.target.style.borderColor = '#111827'}
@@ -658,7 +657,7 @@ export default function LoginPage(): React.ReactElement {
{t('common.or')}
-
+
{title}
@@ -46,37 +56,94 @@ function Section({ title, icon: Icon, children }: SectionProps): React.ReactElem
)
}
+function NotificationPreferences({ t, memoriesEnabled }: { t: any; memoriesEnabled: boolean }) {
+ const [prefs, setPrefs] = useState | null>(null)
+ const [addons, setAddons] = useState>({})
+ useEffect(() => { notificationsApi.getPreferences().then(d => setPrefs(d.preferences)).catch(() => {}) }, [])
+ useEffect(() => {
+ apiClient.get('/addons').then(r => {
+ const map: Record = {}
+ for (const a of (r.data.addons || [])) map[a.id] = !!a.enabled
+ setAddons(map)
+ }).catch(() => {})
+ }, [])
+
+ const toggle = async (key: string) => {
+ if (!prefs) return
+ const newVal = prefs[key] ? 0 : 1
+ setPrefs(prev => prev ? { ...prev, [key]: newVal } : prev)
+ try { await notificationsApi.updatePreferences({ [key]: !!newVal }) } catch {}
+ }
+
+ if (!prefs) return {t('common.loading')}
+
+ const options = [
+ { key: 'notify_trip_invite', label: t('settings.notifyTripInvite') },
+ { key: 'notify_booking_change', label: t('settings.notifyBookingChange') },
+ ...(addons.vacay ? [{ key: 'notify_vacay_invite', label: t('settings.notifyVacayInvite') }] : []),
+ ...(memoriesEnabled ? [{ key: 'notify_photos_shared', label: t('settings.notifyPhotosShared') }] : []),
+ ...(addons.collab ? [{ key: 'notify_collab_message', label: t('settings.notifyCollabMessage') }] : []),
+ ...(addons.documents ? [{ key: 'notify_packing_tagged', label: t('settings.notifyPackingTagged') }] : []),
+ { key: 'notify_webhook', label: t('settings.notifyWebhook') },
+ ]
+
+ return (
+
+ {options.map(opt => (
+
+ {opt.label}
+ toggle(opt.key)}
+ style={{
+ position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
+ background: prefs[opt.key] ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
+ transition: 'background 0.2s',
+ }}>
+
+
+
+ ))}
+
+ )
+}
+
export default function SettingsPage(): React.ReactElement {
- const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode } = useAuthStore()
+ const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode, appRequireMfa } = useAuthStore()
+ const [searchParams] = useSearchParams()
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const avatarInputRef = React.useRef(null)
const { settings, updateSetting, updateSettings } = useSettingsStore()
+ const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
const { t, locale } = useTranslation()
const toast = useToast()
const navigate = useNavigate()
const [saving, setSaving] = useState>({})
- // Immich
- const [memoriesEnabled, setMemoriesEnabled] = useState(false)
+ // Addon gating (derived from store)
+ const memoriesEnabled = addonEnabled('memories')
+ const mcpEnabled = addonEnabled('mcp')
const [immichUrl, setImmichUrl] = useState('')
const [immichApiKey, setImmichApiKey] = useState('')
const [immichConnected, setImmichConnected] = useState(false)
const [immichTesting, setImmichTesting] = useState(false)
useEffect(() => {
- apiClient.get('/addons').then(r => {
- const mem = r.data.addons?.find((a: any) => a.id === 'memories' && a.enabled)
- setMemoriesEnabled(!!mem)
- if (mem) {
- apiClient.get('/integrations/immich/settings').then(r2 => {
- setImmichUrl(r2.data.immich_url || '')
- setImmichConnected(r2.data.connected)
- }).catch(() => {})
- }
- }).catch(() => {})
+ loadAddons()
}, [])
+ useEffect(() => {
+ if (memoriesEnabled) {
+ apiClient.get('/integrations/immich/settings').then(r2 => {
+ setImmichUrl(r2.data.immich_url || '')
+ setImmichConnected(r2.data.connected)
+ }).catch(() => {})
+ }
+ }, [memoriesEnabled])
+
const handleSaveImmich = async () => {
setSaving(s => ({ ...s, immich: true }))
try {
@@ -110,6 +177,67 @@ export default function SettingsPage(): React.ReactElement {
}
}
+ // MCP tokens
+ const [mcpTokens, setMcpTokens] = useState([])
+ const [mcpModalOpen, setMcpModalOpen] = useState(false)
+ const [mcpNewName, setMcpNewName] = useState('')
+ const [mcpCreatedToken, setMcpCreatedToken] = useState(null)
+ const [mcpCreating, setMcpCreating] = useState(false)
+ const [mcpDeleteId, setMcpDeleteId] = useState(null)
+ const [copiedKey, setCopiedKey] = useState(null)
+
+ useEffect(() => {
+ authApi.mcpTokens.list().then(d => setMcpTokens(d.tokens || [])).catch(() => {})
+ }, [])
+
+ const handleCreateMcpToken = async () => {
+ if (!mcpNewName.trim()) return
+ setMcpCreating(true)
+ try {
+ const d = await authApi.mcpTokens.create(mcpNewName.trim())
+ setMcpCreatedToken(d.token.raw_token)
+ setMcpNewName('')
+ setMcpTokens(prev => [{ id: d.token.id, name: d.token.name, token_prefix: d.token.token_prefix, created_at: d.token.created_at, last_used_at: null }, ...prev])
+ } catch {
+ toast.error(t('settings.mcp.toast.createError'))
+ } finally {
+ setMcpCreating(false)
+ }
+ }
+
+ const handleDeleteMcpToken = async (id: number) => {
+ try {
+ await authApi.mcpTokens.delete(id)
+ setMcpTokens(prev => prev.filter(tk => tk.id !== id))
+ setMcpDeleteId(null)
+ toast.success(t('settings.mcp.toast.deleted'))
+ } catch {
+ toast.error(t('settings.mcp.toast.deleteError'))
+ }
+ }
+
+ const handleCopy = (text: string, key: string) => {
+ navigator.clipboard.writeText(text).then(() => {
+ setCopiedKey(key)
+ setTimeout(() => setCopiedKey(null), 2000)
+ })
+ }
+
+ const mcpEndpoint = `${window.location.origin}/mcp`
+ const mcpJsonConfig = `{
+ "mcpServers": {
+ "trek": {
+ "command": "npx",
+ "args": [
+ "mcp-remote",
+ "${mcpEndpoint}",
+ "--header",
+ "Authorization: Bearer "
+ ]
+ }
+ }
+}`
+
// Map settings
const [mapTileUrl, setMapTileUrl] = useState(settings.map_tile_url || '')
const [defaultLat, setDefaultLat] = useState(settings.default_lat || 48.8566)
@@ -139,6 +267,71 @@ export default function SettingsPage(): React.ReactElement {
const [mfaDisablePwd, setMfaDisablePwd] = useState('')
const [mfaDisableCode, setMfaDisableCode] = useState('')
const [mfaLoading, setMfaLoading] = useState(false)
+ const mfaRequiredByPolicy =
+ !demoMode &&
+ !user?.mfa_enabled &&
+ (searchParams.get('mfa') === 'required' || appRequireMfa)
+
+ const [backupCodes, setBackupCodes] = useState(null)
+
+ const backupCodesText = backupCodes?.join('\n') || ''
+
+ // Restore backup codes panel after refresh (loadUser silent fix + sessionStorage)
+ useEffect(() => {
+ if (!user?.mfa_enabled || backupCodes) return
+ try {
+ const raw = sessionStorage.getItem(MFA_BACKUP_SESSION_KEY)
+ if (!raw) return
+ const parsed = JSON.parse(raw) as unknown
+ if (Array.isArray(parsed) && parsed.length > 0 && parsed.every((x) => typeof x === 'string')) {
+ setBackupCodes(parsed)
+ }
+ } catch {
+ sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
+ }
+ }, [user?.mfa_enabled, backupCodes])
+
+ const dismissBackupCodes = (): void => {
+ sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
+ setBackupCodes(null)
+ }
+
+ const copyBackupCodes = async (): Promise => {
+ if (!backupCodesText) return
+ try {
+ await navigator.clipboard.writeText(backupCodesText)
+ toast.success(t('settings.mfa.backupCopied'))
+ } catch {
+ toast.error(t('common.error'))
+ }
+ }
+
+ const downloadBackupCodes = (): void => {
+ if (!backupCodesText) return
+ const blob = new Blob([backupCodesText + '\n'], { type: 'text/plain;charset=utf-8' })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = 'trek-mfa-backup-codes.txt'
+ document.body.appendChild(a)
+ a.click()
+ a.remove()
+ URL.revokeObjectURL(url)
+ }
+
+ const printBackupCodes = (): void => {
+ if (!backupCodesText) return
+ const html = `TREK MFA Backup Codes
+
+ TREK MFA Backup Codes${new Date().toLocaleString()} ${backupCodesText}`
+ const w = window.open('', '_blank', 'width=900,height=700')
+ if (!w) return
+ w.document.open()
+ w.document.write(html)
+ w.document.close()
+ w.focus()
+ w.print()
+ }
useEffect(() => {
setMapTileUrl(settings.map_tile_url || '')
@@ -220,12 +413,15 @@ export default function SettingsPage(): React.ReactElement {
-
-
+
+
+
{t('settings.title')}
{t('settings.subtitle')}
+
+
{/* Map settings */}
@@ -439,6 +635,41 @@ export default function SettingsPage(): React.ReactElement {
))}
+
+ {/* Blur Booking Codes */}
+
+
+
+ {[
+ { value: true, label: t('settings.on') || 'On' },
+ { value: false, label: t('settings.off') || 'Off' },
+ ].map(opt => (
+ {
+ try { await updateSetting('blur_booking_codes', opt.value) }
+ catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
+ }}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 8,
+ padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
+ fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
+ border: (!!settings.blur_booking_codes) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
+ background: (!!settings.blur_booking_codes) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
+ color: 'var(--text-primary)',
+ transition: 'all 0.15s',
+ }}
+ >
+ {opt.label}
+
+ ))}
+
+
+
+
+ {/* Notifications */}
+
{/* Immich — only when Memories addon is enabled */}
@@ -480,6 +711,162 @@ export default function SettingsPage(): React.ReactElement {
)}
+ {/* MCP Configuration — only when MCP addon is enabled */}
+ {mcpEnabled &&
+ {/* Endpoint URL */}
+
+
+
+
+ {mcpEndpoint}
+
+ handleCopy(mcpEndpoint, 'endpoint')}
+ className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
+ style={{ borderColor: 'var(--border-primary)' }} title={t('settings.mcp.copy')}>
+ {copiedKey === 'endpoint' ? : }
+
+
+
+
+ {/* JSON config box */}
+
+
+
+ handleCopy(mcpJsonConfig, 'json')}
+ className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
+ style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
+ {copiedKey === 'json' ? : }
+ {copiedKey === 'json' ? t('settings.mcp.copied') : t('settings.mcp.copy')}
+
+
+
+ {mcpJsonConfig}
+
+ {t('settings.mcp.clientConfigHint')}
+
+
+ {/* Token list */}
+
+
+
+ { setMcpModalOpen(true); setMcpCreatedToken(null); setMcpNewName('') }}
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
+ style={{ background: 'var(--accent-primary, #4f46e5)', color: '#fff' }}>
+ {t('settings.mcp.createToken')}
+
+
+
+ {mcpTokens.length === 0 ? (
+
+ {t('settings.mcp.noTokens')}
+
+ ) : (
+
+ {mcpTokens.map((token, i) => (
+
+
+ {token.name}
+
+ {token.token_prefix}...
+ {t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}
+ {token.last_used_at && (
+ · {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}
+ )}
+
+
+ setMcpDeleteId(token.id)}
+ className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
+ style={{ color: 'var(--text-tertiary)' }} title={t('settings.mcp.deleteTokenTitle')}>
+
+
+
+ ))}
+
+ )}
+
+ }
+
+ {/* Create MCP Token modal */}
+ {mcpModalOpen && (
+ { if (e.target === e.currentTarget && !mcpCreatedToken) { setMcpModalOpen(false) } }}>
+
+ {!mcpCreatedToken ? (
+ <>
+ {t('settings.mcp.modal.createTitle')}
+
+
+ setMcpNewName(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()}
+ placeholder={t('settings.mcp.modal.tokenNamePlaceholder')}
+ className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300"
+ style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
+ autoFocus />
+
+
+ setMcpModalOpen(false)}
+ className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
+ {t('common.cancel')}
+
+
+ {mcpCreating ? t('settings.mcp.modal.creating') : t('settings.mcp.modal.create')}
+
+
+ >
+ ) : (
+ <>
+ {t('settings.mcp.modal.createdTitle')}
+
+ ⚠
+ {t('settings.mcp.modal.createdWarning')}
+
+
+
+ {mcpCreatedToken}
+
+ handleCopy(mcpCreatedToken, 'new-token')}
+ className="absolute top-2 right-2 p-1.5 rounded transition-colors hover:bg-slate-200 dark:hover:bg-slate-600"
+ style={{ color: 'var(--text-secondary)' }} title={t('settings.mcp.copy')}>
+ {copiedKey === 'new-token' ? : }
+
+
+
+ { setMcpModalOpen(false); setMcpCreatedToken(null) }}
+ className="px-4 py-2 rounded-lg text-sm font-medium text-white"
+ style={{ background: 'var(--accent-primary, #4f46e5)' }}>
+ {t('settings.mcp.modal.done')}
+
+
+ >
+ )}
+
+
+ )}
+
+ {/* Delete MCP Token confirm */}
+ {mcpDeleteId !== null && (
+ { if (e.target === e.currentTarget) setMcpDeleteId(null) }}>
+
+ {t('settings.mcp.deleteTokenTitle')}
+ {t('settings.mcp.deleteTokenMessage')}
+
+ setMcpDeleteId(null)}
+ className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
+ {t('common.cancel')}
+
+ handleDeleteMcpToken(mcpDeleteId)}
+ className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
+ {t('settings.mcp.deleteTokenTitle')}
+
+
+
+
+ )}
+
{/* Account */}
@@ -560,6 +947,19 @@ export default function SettingsPage(): React.ReactElement {
{t('settings.mfa.title')}
+ {mfaRequiredByPolicy && (
+
+
+ {t('settings.mfa.requiredByPolicy')}
+
+ )}
{t('settings.mfa.description')}
{demoMode ? (
{t('settings.mfa.demoBlocked')}
@@ -617,12 +1017,21 @@ export default function SettingsPage(): React.ReactElement {
onClick={async () => {
setMfaLoading(true)
try {
- await authApi.mfaEnable({ code: mfaSetupCode })
+ const resp = await authApi.mfaEnable({ code: mfaSetupCode }) as { backup_codes?: string[] }
toast.success(t('settings.mfa.toastEnabled'))
setMfaQr(null)
setMfaSecret(null)
setMfaSetupCode('')
- await loadUser()
+ const codes = resp.backup_codes || null
+ if (codes?.length) {
+ try {
+ sessionStorage.setItem(MFA_BACKUP_SESSION_KEY, JSON.stringify(codes))
+ } catch {
+ /* ignore quota / private mode */
+ }
+ }
+ setBackupCodes(codes)
+ await loadUser({ silent: true })
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
@@ -674,7 +1083,9 @@ export default function SettingsPage(): React.ReactElement {
toast.success(t('settings.mfa.toastDisabled'))
setMfaDisablePwd('')
setMfaDisableCode('')
- await loadUser()
+ sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
+ setBackupCodes(null)
+ await loadUser({ silent: true })
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
@@ -687,6 +1098,29 @@ export default function SettingsPage(): React.ReactElement {
)}
+
+ {backupCodes && backupCodes.length > 0 && (
+
+ {t('settings.mfa.backupTitle')}
+ {t('settings.mfa.backupDescription')}
+ {backupCodesText}
+ {t('settings.mfa.backupWarning')}
+
+
+ {t('settings.mfa.backupCopy')}
+
+
+ {t('settings.mfa.backupDownload')}
+
+
+ {t('settings.mfa.backupPrint')}
+
+
+ {t('common.ok')}
+
+
+
+ )}
>
)}
@@ -888,6 +1322,7 @@ export default function SettingsPage(): React.ReactElement {
)}
+
diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx
new file mode 100644
index 0000000..4d304a6
--- /dev/null
+++ b/client/src/pages/SharedTripPage.tsx
@@ -0,0 +1,396 @@
+import { useState, useEffect } from 'react'
+import { useParams } from 'react-router-dom'
+import { MapContainer, TileLayer, Marker, Tooltip, useMap } from 'react-leaflet'
+import L from 'leaflet'
+import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
+import { useSettingsStore } from '../store/settingsStore'
+import { getLocaleForLanguage } from '../i18n'
+import { shareApi } from '../api/client'
+import { getCategoryIcon } from '../components/shared/categoryIcons'
+import { createElement } from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
+
+const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
+const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
+
+function createMarkerIcon(place: any) {
+ const cat = place.category
+ const color = cat?.color || '#6366f1'
+ const CatIcon = getCategoryIcon(cat?.icon)
+ const iconSvg = renderToStaticMarkup(createElement(CatIcon, { size: 14, strokeWidth: 2, color: 'white' }))
+ return L.divIcon({
+ className: '',
+ iconSize: [28, 28],
+ iconAnchor: [14, 14],
+ html: `${iconSvg} `,
+ })
+}
+
+function FitBoundsToPlaces({ places }: { places: any[] }) {
+ const map = useMap()
+ useEffect(() => {
+ if (places.length === 0) return
+ const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng]))
+ map.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 })
+ }, [places, map])
+ return null
+}
+
+export default function SharedTripPage() {
+ const { token } = useParams<{ token: string }>()
+ const { t, locale } = useTranslation()
+ const [data, setData] = useState(null)
+ const [error, setError] = useState(false)
+ const [selectedDay, setSelectedDay] = useState(null)
+ const [activeTab, setActiveTab] = useState('plan')
+ const [showLangPicker, setShowLangPicker] = useState(false)
+
+ useEffect(() => {
+ if (!token) return
+ shareApi.getSharedTrip(token).then(setData).catch(() => setError(true))
+ }, [token])
+
+ if (error) return (
+
+
+ 🔒
+ {t('shared.expired')}
+ {t('shared.expiredHint')}
+
+
+ )
+
+ if (!data) return (
+
+ )
+
+ const { trip, days, assignments, dayNotes, places, reservations, accommodations, packing, budget, categories, permissions, collab } = data
+ const sortedDays = [...(days || [])].sort((a: any, b: any) => a.day_number - b.day_number)
+
+ // Map places
+ const mapPlaces = selectedDay
+ ? (assignments[String(selectedDay)] || []).map((a: any) => a.place).filter((p: any) => p?.lat && p?.lng)
+ : (places || []).filter((p: any) => p?.lat && p?.lng)
+
+ const center = mapPlaces.length > 0 ? [mapPlaces[0].lat, mapPlaces[0].lng] : [48.85, 2.35]
+
+ return (
+
+ {/* Header */}
+
+ {/* Cover image background */}
+ {trip.cover_image && (
+
+ )}
+ {/* Background decoration */}
+
+
+
+ {/* Logo */}
+
+ 
+
+
+ Travel Resource & Exploration Kit
+
+ {trip.title}
+
+ {trip.description && (
+ {trip.description}
+ )}
+
+ {(trip.start_date || trip.end_date) && (
+
+
+ {[trip.start_date, trip.end_date].filter(Boolean).map((d: string) => new Date(d + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })).join(' — ')}
+
+ {days?.length > 0 && ·}
+ {days?.length > 0 && {days.length} {t('shared.days')}}
+
+ )}
+
+ {t('shared.readOnly')}
+
+ {/* Language picker - top right */}
+
+ setShowLangPicker(v => !v)} style={{
+ padding: '5px 12px', borderRadius: 20, border: '1px solid rgba(255,255,255,0.15)',
+ background: 'rgba(255,255,255,0.1)', backdropFilter: 'blur(8px)',
+ color: 'rgba(255,255,255,0.7)', fontSize: 11, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
+ }}>
+ {SUPPORTED_LANGUAGES.find(l => l.value === (locale?.split('-')[0] || 'en'))?.label || 'Language'}
+
+ {showLangPicker && (
+
+ {SUPPORTED_LANGUAGES.map(lang => (
+ {
+ // Set language locally without API call (shared page has no auth)
+ useSettingsStore.setState(s => ({ settings: { ...s.settings, language: lang.value } }))
+ setShowLangPicker(false)
+ }}
+ style={{ display: 'block', width: '100%', padding: '6px 12px', border: 'none', background: 'none', textAlign: 'left', cursor: 'pointer', fontSize: 12, color: '#374151', borderRadius: 6, fontFamily: 'inherit' }}
+ onMouseEnter={e => e.currentTarget.style.background = '#f3f4f6'}
+ onMouseLeave={e => e.currentTarget.style.background = 'none'}
+ >{lang.label}
+ ))}
+
+ )}
+
+
+
+
+ {/* Tabs */}
+
+ {[
+ { id: 'plan', label: t('shared.tabPlan'), Icon: Map },
+ ...(permissions?.share_bookings ? [{ id: 'bookings', label: t('shared.tabBookings'), Icon: Ticket }] : []),
+ ...(permissions?.share_packing ? [{ id: 'packing', label: t('shared.tabPacking'), Icon: Luggage }] : []),
+ ...(permissions?.share_budget ? [{ id: 'budget', label: t('shared.tabBudget'), Icon: Wallet }] : []),
+ ...(permissions?.share_collab ? [{ id: 'collab', label: t('shared.tabChat'), Icon: MessageCircle }] : []),
+ ].map(tab => (
+ setActiveTab(tab.id)} style={{
+ padding: '8px 18px', borderRadius: 12, border: '1.5px solid', cursor: 'pointer',
+ fontSize: 12, fontWeight: 600, fontFamily: 'inherit', transition: 'all 0.15s', whiteSpace: 'nowrap',
+ display: 'flex', alignItems: 'center', gap: 6,
+ background: activeTab === tab.id ? '#111827' : 'var(--bg-card, white)',
+ borderColor: activeTab === tab.id ? '#111827' : 'var(--border-faint, #e5e7eb)',
+ color: activeTab === tab.id ? 'white' : '#6b7280',
+ boxShadow: activeTab === tab.id ? '0 2px 8px rgba(0,0,0,0.15)' : '0 1px 3px rgba(0,0,0,0.04)',
+ }}>{tab.label}
+ ))}
+
+
+ {/* Map */}
+ {activeTab === 'plan' && (<>
+
+
+
+
+ {mapPlaces.map((p: any) => (
+
+ {p.name}
+
+ ))}
+
+
+
+ {/* Day Plan */}
+
+ {sortedDays.map((day: any, di: number) => {
+ const da = assignments[String(day.id)] || []
+ const notes = (dayNotes[String(day.id)] || [])
+ const dayTransport = (reservations || []).filter((r: any) => TRANSPORT_TYPES.has(r.type) && r.reservation_time?.split('T')[0] === day.date)
+ const dayAccs = (accommodations || []).filter((a: any) => day.id >= a.start_day_id && day.id <= a.end_day_id)
+
+ const merged = [
+ ...da.map((a: any) => ({ type: 'place', k: a.order_index, data: a })),
+ ...notes.map((n: any) => ({ type: 'note', k: n.sort_order ?? 0, data: n })),
+ ...dayTransport.map((r: any) => ({ type: 'transport', k: r.day_plan_position ?? 999, data: r })),
+ ].sort((a, b) => a.k - b.k)
+
+ return (
+
+ setSelectedDay(selectedDay === day.id ? null : day.id)}
+ style={{ padding: '12px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 }}>
+ {di + 1}
+
+ {day.title || `Day ${day.day_number}`}
+ {day.date && {new Date(day.date + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })} }
+
+ {dayAccs.map((acc: any) => (
+
+ {acc.place_name}
+
+ ))}
+ {da.length} {t('shared.places')}
+
+
+ {selectedDay === day.id && merged.length > 0 && (
+
+ {merged.map((item: any, idx: number) => {
+ if (item.type === 'transport') {
+ const r = item.data
+ const TIcon = TRANSPORT_ICONS[r.type] || Ticket
+ const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
+ const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
+ let sub = ''
+ if (r.type === 'flight') sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
+ else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
+ return (
+
+
+
+
+
+ {r.title}{time ? ` · ${time}` : ''}
+ {sub && {sub} }
+
+
+ )
+ }
+ if (item.type === 'note') {
+ return (
+
+
+
+ {item.data.text}
+ {item.data.time && {item.data.time} }
+
+
+ )
+ }
+ const place = item.data.place
+ if (!place) return null
+ const cat = categories?.find((c: any) => c.id === place.category_id)
+ return (
+
+
+ {place.image_url ?  : }
+
+
+ {place.name}
+ {(place.address || place.description) && {place.address || place.description} }
+
+ {place.place_time && {place.place_time}{place.end_time ? ` – ${place.end_time}` : ''}}
+
+ )
+ })}
+
+ )}
+
+ )
+ })}
+
+ >)}
+
+ {/* Bookings */}
+ {activeTab === 'bookings' && (reservations || []).length > 0 && (
+
+ {(reservations || []).map((r: any) => {
+ const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
+ const TIcon = TRANSPORT_ICONS[r.type] || Ticket
+ const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
+ const date = r.reservation_time ? new Date(r.reservation_time.includes('T') ? r.reservation_time : r.reservation_time + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' }) : ''
+ return (
+
+
+
+
+
+ {r.title}
+
+ {date && {date}}
+ {time && {time}}
+ {r.location && {r.location}}
+ {meta.airline && {meta.airline} {meta.flight_number || ''}}
+ {meta.train_number && {meta.train_number}}
+
+
+
+ {r.status === 'confirmed' ? t('shared.confirmed') : t('shared.pending')}
+
+
+ )
+ })}
+
+ )}
+
+ {/* Packing */}
+ {activeTab === 'packing' && (packing || []).length > 0 && (
+
+ {Object.entries((packing || []).reduce((g: any, i: any) => { const c = i.category || t('shared.other'); (g[c] = g[c] || []).push(i); return g }, {})).map(([cat, items]: [string, any]) => (
+
+ {cat}
+ {items.map((item: any) => (
+
+ {item.name}
+
+ ))}
+
+ ))}
+
+ )}
+
+ {/* Budget */}
+ {activeTab === 'budget' && (budget || []).length > 0 && (() => {
+ const grouped = (budget || []).reduce((g: any, i: any) => { const c = i.category || t('shared.other'); (g[c] = g[c] || []).push(i); return g }, {})
+ const total = (budget || []).reduce((s: number, i: any) => s + (parseFloat(i.total_price) || 0), 0)
+ return (
+
+ {/* Total card */}
+
+ {t('shared.totalBudget')}
+ {total.toLocaleString(locale, { minimumFractionDigits: 2 })} {trip.currency || 'EUR'}
+
+ {/* By category */}
+ {Object.entries(grouped).map(([cat, items]: [string, any]) => (
+
+
+ {cat}
+ {items.reduce((s: number, i: any) => s + (parseFloat(i.total_price) || 0), 0).toLocaleString(locale, { minimumFractionDigits: 2 })} {trip.currency || ''}
+
+ {items.map((item: any) => (
+
+ {item.name}
+ {item.total_price ? Number(item.total_price).toLocaleString(locale, { minimumFractionDigits: 2 }) : '—'}
+
+ ))}
+
+ ))}
+
+ )
+ })()}
+
+ {/* Collab Chat */}
+ {activeTab === 'collab' && (collab || []).length > 0 && (
+
+
+
+ {t('shared.tabChat')} · {(collab || []).length} {t('shared.messages')}
+
+
+ {(collab || []).map((msg: any, i: number) => {
+ const prevMsg = i > 0 ? collab[i - 1] : null
+ const showDate = !prevMsg || new Date(msg.created_at).toDateString() !== new Date(prevMsg.created_at).toDateString()
+ return (
+
+ {showDate && (
+
+ {new Date(msg.created_at).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}
+
+ )}
+
+
+ {msg.avatar ?  : (msg.username || '?')[0].toUpperCase()}
+
+
+
+ {msg.username}
+ {new Date(msg.created_at).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })}
+
+ {msg.text}
+
+
+
+ )
+ })}
+
+
+ )}
+
+ {/* Footer */}
+
+
+ 
+ {t('shared.sharedVia')} TREK
+
+ Made with ♥ by Maurice · GitHub
+
+
+
+ )
+}
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index de659f8..7636a86 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -446,6 +446,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
accommodations={tripAccommodations}
+ onNavigateToFiles={() => handleTabChange('dateien')}
/>
{!leftCollapsed && (
setShowDayDetail(null)}
onAccommodationChange={loadAccommodations}
+ leftWidth={leftCollapsed ? 0 : leftWidth}
+ rightWidth={rightCollapsed ? 0 : rightWidth}
/>
)
})()}
@@ -588,6 +592,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
} catch {}
}}
onUpdatePlace={async (placeId, data) => { try { await tripStore.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }}
+ leftWidth={leftCollapsed ? 0 : leftWidth}
+ rightWidth={rightCollapsed ? 0 : rightWidth}
/>
)}
@@ -602,8 +608,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
{mobileSidebarOpen === 'left'
- ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} />
- : { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} />
+ ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} />
+ : { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} />
}
diff --git a/client/src/store/addonStore.ts b/client/src/store/addonStore.ts
new file mode 100644
index 0000000..d0fce97
--- /dev/null
+++ b/client/src/store/addonStore.ts
@@ -0,0 +1,35 @@
+import { create } from 'zustand'
+import { addonsApi } from '../api/client'
+
+interface Addon {
+ id: string
+ name: string
+ type: string
+ icon: string
+ enabled: boolean
+}
+
+interface AddonState {
+ addons: Addon[]
+ loaded: boolean
+ loadAddons: () => Promise
+ isEnabled: (id: string) => boolean
+}
+
+export const useAddonStore = create((set, get) => ({
+ addons: [],
+ loaded: false,
+
+ loadAddons: async () => {
+ try {
+ const data = await addonsApi.enabled()
+ set({ addons: data.addons || [], loaded: true })
+ } catch {
+ set({ loaded: true })
+ }
+ },
+
+ isEnabled: (id: string) => {
+ return get().addons.some(a => a.id === id && a.enabled)
+ },
+}))
diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts
index 1ce5211..472d2d9 100644
--- a/client/src/store/authStore.ts
+++ b/client/src/store/authStore.ts
@@ -23,12 +23,16 @@ interface AuthState {
error: string | null
demoMode: boolean
hasMapsKey: boolean
+ serverTimezone: string
+ /** Server policy: all users must enable MFA */
+ appRequireMfa: boolean
login: (email: string, password: string) => Promise
completeMfaLogin: (mfaToken: string, code: string) => Promise
register: (username: string, email: string, password: string) => Promise
logout: () => void
- loadUser: () => Promise
+ /** Pass `{ silent: true }` to refresh the user without toggling global isLoading (avoids unmounting protected routes). */
+ loadUser: (opts?: { silent?: boolean }) => Promise
updateMapsKey: (key: string | null) => Promise
updateApiKeys: (keys: Record) => Promise
updateProfile: (profileData: Partial) => Promise
@@ -36,6 +40,8 @@ interface AuthState {
deleteAvatar: () => Promise
setDemoMode: (val: boolean) => void
setHasMapsKey: (val: boolean) => void
+ setServerTimezone: (tz: string) => void
+ setAppRequireMfa: (val: boolean) => void
demoLogin: () => Promise
}
@@ -47,6 +53,8 @@ export const useAuthStore = create((set, get) => ({
error: null,
demoMode: localStorage.getItem('demo_mode') === 'true',
hasMapsKey: false,
+ serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ appRequireMfa: false,
login: async (email: string, password: string) => {
set({ isLoading: true, error: null })
@@ -126,13 +134,14 @@ export const useAuthStore = create((set, get) => ({
})
},
- loadUser: async () => {
+ loadUser: async (opts?: { silent?: boolean }) => {
+ const silent = !!opts?.silent
const token = get().token
if (!token) {
- set({ isLoading: false })
+ if (!silent) set({ isLoading: false })
return
}
- set({ isLoading: true })
+ if (!silent) set({ isLoading: true })
try {
const data = await authApi.me()
set({
@@ -201,6 +210,8 @@ export const useAuthStore = create((set, get) => ({
},
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
+ setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
+ setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }),
demoLogin: async () => {
set({ isLoading: true, error: null })
diff --git a/client/src/types.ts b/client/src/types.ts
index 7bc49dd..b216b63 100644
--- a/client/src/types.ts
+++ b/client/src/types.ts
@@ -118,15 +118,22 @@ export interface Reservation {
trip_id: number
name: string
title?: string
- type: string | null
+ type: string
status: 'pending' | 'confirmed'
date: string | null
time: string | null
+ reservation_time?: string | null
+ reservation_end_time?: string | null
+ location?: string | null
confirmation_number: string | null
notes: string | null
url: string | null
+ day_id?: number | null
+ place_id?: number | null
+ assignment_id?: number | null
accommodation_id?: number | null
- metadata?: Record | null
+ day_plan_position?: number | null
+ metadata?: Record | string | null
created_at: string
}
@@ -148,6 +155,7 @@ export interface TripFile {
deleted_at?: string | null
created_at: string
reservation_title?: string
+ linked_reservation_ids?: number[]
url?: string
}
@@ -163,6 +171,7 @@ export interface Settings {
time_format: string
show_place_description: boolean
route_calculation?: boolean
+ blur_booking_codes?: boolean
}
export interface AssignmentsMap {
@@ -271,6 +280,9 @@ export interface AppConfig {
oidc_display_name?: string
has_maps_key?: boolean
allowed_file_types?: string
+ timezone?: string
+ /** When true, users without MFA cannot use the app until they enable it */
+ require_mfa?: boolean
}
// Translation function type
@@ -361,7 +373,7 @@ export function getApiErrorMessage(err: unknown, fallback: string): string {
// MergedItem used in day notes hook
export interface MergedItem {
- type: 'assignment' | 'note'
+ type: 'assignment' | 'note' | 'place' | 'transport'
sortKey: number
- data: Assignment | DayNote
+ data: Assignment | DayNote | Reservation
}
diff --git a/client/src/utils/formatters.ts b/client/src/utils/formatters.ts
index 4fd3409..7e7ebc0 100644
--- a/client/src/utils/formatters.ts
+++ b/client/src/utils/formatters.ts
@@ -6,11 +6,13 @@ export function currencyDecimals(currency: string): number {
return ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 0 : 2
}
-export function formatDate(dateStr: string | null | undefined, locale: string): string | null {
+export function formatDate(dateStr: string | null | undefined, locale: string, timeZone?: string): string | null {
if (!dateStr) return null
- return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, {
+ const opts: Intl.DateTimeFormatOptions = {
weekday: 'short', day: 'numeric', month: 'short',
- })
+ }
+ if (timeZone) opts.timeZone = timeZone
+ return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, opts)
}
export function formatTime(timeStr: string | null | undefined, locale: string, timeFormat: string): string {
diff --git a/client/vite.config.js b/client/vite.config.js
index a2d3632..fbbe41e 100644
--- a/client/vite.config.js
+++ b/client/vite.config.js
@@ -8,9 +8,10 @@ export default defineConfig({
VitePWA({
registerType: 'autoUpdate',
workbox: {
+ maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
navigateFallback: 'index.html',
- navigateFallbackDenylist: [/^\/api/, /^\/uploads/],
+ navigateFallbackDenylist: [/^\/api/, /^\/uploads/, /^\/mcp/],
runtimeCaching: [
{
// Carto map tiles (default provider)
@@ -100,6 +101,10 @@ export default defineConfig({
'/ws': {
target: 'http://localhost:3001',
ws: true,
+ },
+ '/mcp': {
+ target: 'http://localhost:3001',
+ changeOrigin: true,
}
}
}
diff --git a/docker-compose.yml b/docker-compose.yml
index 1acc607..300cc97 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,7 +1,23 @@
services:
+ init-permissions:
+ image: alpine:3.20
+ container_name: trek-init-permissions
+ user: "0:0"
+ command: >
+ sh -c "mkdir -p /app/data /app/uploads &&
+ chown -R 1000:1000 /app/data /app/uploads &&
+ chmod -R u+rwX /app/data /app/uploads"
+ volumes:
+ - ./data:/app/data
+ - ./uploads:/app/uploads
+ restart: "no"
+
app:
image: mauriceboe/trek:latest
container_name: trek
+ depends_on:
+ init-permissions:
+ condition: service_completed_successfully
ports:
- "3000:3000"
environment:
@@ -9,6 +25,7 @@ services:
- JWT_SECRET=${JWT_SECRET:-}
# - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins
- PORT=3000
+ - TZ=${TZ:-UTC}
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
diff --git a/docs/TREK-Generated-by-MCP.pdf b/docs/TREK-Generated-by-MCP.pdf
new file mode 100644
index 0000000..70a0964
Binary files /dev/null and b/docs/TREK-Generated-by-MCP.pdf differ
diff --git a/docs/screenshot-trip-mcp.png b/docs/screenshot-trip-mcp.png
new file mode 100644
index 0000000..30a19e6
Binary files /dev/null and b/docs/screenshot-trip-mcp.png differ
diff --git a/server/.env.example b/server/.env.example
index 188bf55..4e2e99e 100644
--- a/server/.env.example
+++ b/server/.env.example
@@ -1,3 +1,4 @@
PORT=3001
JWT_SECRET=your-super-secret-jwt-key-change-in-production
NODE_ENV=development
+DEBUG=false
diff --git a/server/package-lock.json b/server/package-lock.json
index dd28711..f4af0a6 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -1,13 +1,14 @@
{
"name": "trek-server",
- "version": "2.6.2",
+ "version": "2.7.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-server",
- "version": "2.6.2",
+ "version": "2.7.1",
"dependencies": {
+ "@modelcontextprotocol/sdk": "^1.28.0",
"archiver": "^6.0.1",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0",
@@ -19,13 +20,15 @@
"multer": "^2.1.1",
"node-cron": "^4.2.1",
"node-fetch": "^2.7.0",
+ "nodemailer": "^8.0.4",
"otplib": "^12.0.1",
"qrcode": "^1.5.4",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"unzipper": "^0.12.3",
"uuid": "^9.0.0",
- "ws": "^8.19.0"
+ "ws": "^8.19.0",
+ "zod": "^4.3.6"
},
"devDependencies": {
"@types/archiver": "^7.0.0",
@@ -37,6 +40,7 @@
"@types/multer": "^2.1.0",
"@types/node": "^25.5.0",
"@types/node-cron": "^3.0.11",
+ "@types/nodemailer": "^7.0.11",
"@types/qrcode": "^1.5.5",
"@types/unzipper": "^0.10.11",
"@types/uuid": "^10.0.0",
@@ -460,6 +464,358 @@
"node": ">=18"
}
},
+ "node_modules/@hono/node-server": {
+ "version": "1.19.11",
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
+ "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.14.1"
+ },
+ "peerDependencies": {
+ "hono": "^4"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk": {
+ "version": "1.28.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.28.0.tgz",
+ "integrity": "sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw==",
+ "license": "MIT",
+ "dependencies": {
+ "@hono/node-server": "^1.19.9",
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1",
+ "content-type": "^1.0.5",
+ "cors": "^2.8.5",
+ "cross-spawn": "^7.0.5",
+ "eventsource": "^3.0.2",
+ "eventsource-parser": "^3.0.0",
+ "express": "^5.2.1",
+ "express-rate-limit": "^8.2.1",
+ "hono": "^4.11.4",
+ "jose": "^6.1.3",
+ "json-schema-typed": "^8.0.2",
+ "pkce-challenge": "^5.0.0",
+ "raw-body": "^3.0.0",
+ "zod": "^3.25 || ^4.0",
+ "zod-to-json-schema": "^3.25.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@cfworker/json-schema": "^4.1.1",
+ "zod": "^3.25 || ^4.0"
+ },
+ "peerDependenciesMeta": {
+ "@cfworker/json-schema": {
+ "optional": true
+ },
+ "zod": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
+ "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.0",
+ "iconv-lite": "^0.7.0",
+ "on-finished": "^2.4.1",
+ "qs": "^6.14.1",
+ "raw-body": "^3.0.1",
+ "type-is": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
+ "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
+ "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^1.0.5",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/@otplib/core": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
@@ -653,6 +1009,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/nodemailer": {
+ "version": "7.0.11",
+ "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
+ "integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
@@ -760,6 +1126,39 @@
"node": ">= 0.6"
}
},
+ "node_modules/ajv": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -1368,6 +1767,20 @@
"node": ">= 12.0.0"
}
},
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -1643,6 +2056,27 @@
"bare-events": "^2.7.0"
}
},
+ "node_modules/eventsource": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
+ "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
+ "license": "MIT",
+ "dependencies": {
+ "eventsource-parser": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/eventsource-parser": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
+ "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -1698,12 +2132,52 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/express-rate-limit": {
+ "version": "8.3.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
+ "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "10.1.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
"node_modules/fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
"license": "MIT"
},
+ "node_modules/fast-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
+ "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -2006,6 +2480,15 @@
"node": ">=18.0.0"
}
},
+ "node_modules/hono": {
+ "version": "4.12.9",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
+ "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.9.0"
+ }
+ },
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -2088,6 +2571,15 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
+ "node_modules/ip-address": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
+ "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -2152,12 +2644,45 @@
"node": ">=0.12.0"
}
},
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/jose": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
+ "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/json-schema-typed": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
+ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
+ "license": "BSD-2-Clause"
+ },
"node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
@@ -2520,6 +3045,15 @@
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"license": "MIT"
},
+ "node_modules/nodemailer": {
+ "version": "8.0.4",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
+ "integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/nodemon": {
"version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
@@ -2690,6 +3224,15 @@
"node": ">=8"
}
},
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
@@ -2709,6 +3252,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/pkce-challenge": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
+ "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
@@ -2924,6 +3476,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
@@ -2939,6 +3500,55 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/router/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/router/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/router/node_modules/path-to-regexp": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz",
+ "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -3034,6 +3644,27 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -3514,6 +4145,21 @@
"webidl-conversions": "^3.0.0"
}
},
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
@@ -3615,6 +4261,24 @@
"engines": {
"node": ">= 12.0.0"
}
+ },
+ "node_modules/zod": {
+ "version": "4.3.6",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
+ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-to-json-schema": {
+ "version": "3.25.2",
+ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
+ "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "zod": "^3.25.28 || ^4"
+ }
}
}
}
diff --git a/server/package.json b/server/package.json
index 103b706..27b8210 100644
--- a/server/package.json
+++ b/server/package.json
@@ -1,12 +1,13 @@
{
"name": "trek-server",
- "version": "2.7.0",
+ "version": "2.7.1",
"main": "src/index.ts",
"scripts": {
"start": "node --import tsx src/index.ts",
"dev": "tsx watch src/index.ts"
},
"dependencies": {
+ "@modelcontextprotocol/sdk": "^1.28.0",
"archiver": "^6.0.1",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0",
@@ -17,14 +18,16 @@
"jsonwebtoken": "^9.0.2",
"multer": "^2.1.1",
"node-cron": "^4.2.1",
+ "node-fetch": "^2.7.0",
+ "nodemailer": "^8.0.4",
"otplib": "^12.0.1",
"qrcode": "^1.5.4",
- "node-fetch": "^2.7.0",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"unzipper": "^0.12.3",
"uuid": "^9.0.0",
- "ws": "^8.19.0"
+ "ws": "^8.19.0",
+ "zod": "^4.3.6"
},
"devDependencies": {
"@types/archiver": "^7.0.0",
@@ -36,6 +39,7 @@
"@types/multer": "^2.1.0",
"@types/node": "^25.5.0",
"@types/node-cron": "^3.0.11",
+ "@types/nodemailer": "^7.0.11",
"@types/qrcode": "^1.5.5",
"@types/unzipper": "^0.10.11",
"@types/uuid": "^10.0.0",
diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts
index 55078fd..a9adf1e 100644
--- a/server/src/db/migrations.ts
+++ b/server/src/db/migrations.ts
@@ -321,6 +321,112 @@ function runMigrations(db: Database.Database): void {
UNIQUE(file_id, place_id)
)`);
},
+ () => {
+ // Add day_plan_position to reservations for persistent transport ordering in day timeline
+ try { db.exec('ALTER TABLE reservations ADD COLUMN day_plan_position REAL DEFAULT NULL'); } catch {}
+ },
+ () => {
+ // Add paid_by_user_id to budget_items for expense tracking / settlement
+ try { db.exec('ALTER TABLE budget_items ADD COLUMN paid_by_user_id INTEGER REFERENCES users(id)'); } catch {}
+ },
+ () => {
+ // Add target_date to bucket_list for optional visit planning
+ try { db.exec('ALTER TABLE bucket_list ADD COLUMN target_date TEXT DEFAULT NULL'); } catch {}
+ },
+ () => {
+ // Notification preferences per user
+ db.exec(`CREATE TABLE IF NOT EXISTS notification_preferences (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ notify_trip_invite INTEGER DEFAULT 1,
+ notify_booking_change INTEGER DEFAULT 1,
+ notify_trip_reminder INTEGER DEFAULT 1,
+ notify_vacay_invite INTEGER DEFAULT 1,
+ notify_photos_shared INTEGER DEFAULT 1,
+ notify_collab_message INTEGER DEFAULT 1,
+ notify_packing_tagged INTEGER DEFAULT 1,
+ notify_webhook INTEGER DEFAULT 0,
+ UNIQUE(user_id)
+ )`);
+ },
+ () => {
+ // Add missing notification preference columns for existing tables
+ try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_vacay_invite INTEGER DEFAULT 1'); } catch {}
+ try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_photos_shared INTEGER DEFAULT 1'); } catch {}
+ try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_collab_message INTEGER DEFAULT 1'); } catch {}
+ try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_packing_tagged INTEGER DEFAULT 1'); } catch {}
+ },
+ () => {
+ // Public share links for read-only trip access
+ db.exec(`CREATE TABLE IF NOT EXISTS share_tokens (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
+ token TEXT NOT NULL UNIQUE,
+ created_by INTEGER NOT NULL REFERENCES users(id),
+ share_map INTEGER DEFAULT 1,
+ share_bookings INTEGER DEFAULT 1,
+ share_packing INTEGER DEFAULT 0,
+ share_budget INTEGER DEFAULT 0,
+ share_collab INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`);
+ },
+ () => {
+ // Add permission columns to share_tokens
+ try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_map INTEGER DEFAULT 1'); } catch {}
+ try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_bookings INTEGER DEFAULT 1'); } catch {}
+ try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_packing INTEGER DEFAULT 0'); } catch {}
+ try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_budget INTEGER DEFAULT 0'); } catch {}
+ try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_collab INTEGER DEFAULT 0'); } catch {}
+ },
+ () => {
+ // Audit log
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS audit_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ action TEXT NOT NULL,
+ resource TEXT,
+ details TEXT,
+ ip TEXT
+ );
+ CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
+ `);
+ },
+ () => {
+ // MFA backup/recovery codes
+ try { db.exec('ALTER TABLE users ADD COLUMN mfa_backup_codes TEXT'); } catch {}
+ },
+ // MCP long-lived API tokens
+ () => db.exec(`
+ CREATE TABLE IF NOT EXISTS mcp_tokens (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ token_hash TEXT NOT NULL,
+ token_prefix TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ last_used_at DATETIME
+ )
+ `),
+ // MCP addon entry
+ () => {
+ try {
+ db.prepare("INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)")
+ .run('mcp', 'MCP', 'Model Context Protocol for AI assistant integration', 'integration', 'Terminal', 0, 12);
+ } catch {}
+ },
+ // Index on mcp_tokens.token_hash
+ () => db.exec(`
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_mcp_tokens_hash ON mcp_tokens(token_hash)
+ `),
+ // Ensure MCP addon type is 'integration'
+ () => {
+ try {
+ db.prepare("UPDATE addons SET type = 'integration' WHERE id = 'mcp'").run();
+ } catch {}
+ },
];
if (currentVersion < migrations.length) {
diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts
index 425a914..c6a5d23 100644
--- a/server/src/db/schema.ts
+++ b/server/src/db/schema.ts
@@ -17,6 +17,7 @@ function createTables(db: Database.Database): void {
last_login DATETIME,
mfa_enabled INTEGER DEFAULT 0,
mfa_secret TEXT,
+ mfa_backup_codes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -380,6 +381,17 @@ function createTables(db: Database.Database): void {
UNIQUE(assignment_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_assignment_participants_assignment ON assignment_participants(assignment_id);
+
+ CREATE TABLE IF NOT EXISTS audit_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ action TEXT NOT NULL,
+ resource TEXT,
+ details TEXT,
+ ip TEXT
+ );
+ CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
`);
}
diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts
index e98b2f3..9c51ffc 100644
--- a/server/src/db/seeds.ts
+++ b/server/src/db/seeds.ts
@@ -33,6 +33,7 @@ function seedAddons(db: Database.Database): void {
{ id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 },
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', enabled: 1, sort_order: 10 },
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
+ { id: 'mcp', name: 'MCP', description: 'Model Context Protocol for AI assistant integration', type: 'integration', icon: 'Terminal', enabled: 0, sort_order: 12 },
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
];
const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
diff --git a/server/src/index.ts b/server/src/index.ts
index c53977f..db4bba7 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -1,11 +1,14 @@
import 'dotenv/config';
+import './config';
import express, { Request, Response, NextFunction } from 'express';
+import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
import cors from 'cors';
import helmet from 'helmet';
import path from 'path';
import fs from 'fs';
const app = express();
+const DEBUG = String(process.env.DEBUG || 'false').toLowerCase() === 'true';
// Trust first proxy (nginx/Docker) for correct req.ip
if (process.env.NODE_ENV === 'production' || process.env.TRUST_PROXY) {
@@ -79,10 +82,42 @@ if (shouldForceHttps) {
app.use(express.json({ limit: '100kb' }));
app.use(express.urlencoded({ extended: true }));
-// Avatars are public (shown on login, sharing screens)
-app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
+app.use(enforceGlobalMfaPolicy);
-// All other uploads require authentication
+if (DEBUG) {
+ app.use((req: Request, res: Response, next: NextFunction) => {
+ const startedAt = Date.now();
+ const requestId = Math.random().toString(36).slice(2, 10);
+ const redact = (value: unknown): unknown => {
+ if (!value || typeof value !== 'object') return value;
+ if (Array.isArray(value)) return value.map(redact);
+ const hidden = new Set(['password', 'token', 'jwt', 'authorization', 'cookie', 'client_secret', 'mfa_token', 'code']);
+ const out: Record = {};
+ for (const [k, v] of Object.entries(value as Record)) {
+ out[k] = hidden.has(k.toLowerCase()) ? '[REDACTED]' : redact(v);
+ }
+ return out;
+ };
+
+ const safeQuery = redact(req.query);
+ const safeBody = redact(req.body);
+ console.log(`[DEBUG][REQ ${requestId}] ${req.method} ${req.originalUrl} ip=${req.ip} query=${JSON.stringify(safeQuery)} body=${JSON.stringify(safeBody)}`);
+
+ res.on('finish', () => {
+ const elapsedMs = Date.now() - startedAt;
+ console.log(`[DEBUG][RES ${requestId}] ${req.method} ${req.originalUrl} status=${res.statusCode} elapsed_ms=${elapsedMs}`);
+ });
+
+ next();
+ });
+}
+
+// Avatars are public (shown on login, sharing screens)
+import { authenticate } from './middleware/auth';
+app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
+app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers')));
+
+// Serve uploaded files (UUIDs are unguessable, path traversal protected)
app.get('/uploads/:type/:filename', (req: Request, res: Response) => {
const { type, filename } = req.params;
const allowedTypes = ['covers', 'files', 'photos'];
@@ -160,11 +195,31 @@ app.use('/api/weather', weatherRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/backup', backupRoutes);
+import notificationRoutes from './routes/notifications';
+app.use('/api/notifications', notificationRoutes);
+
+import shareRoutes from './routes/share';
+app.use('/api', shareRoutes);
+
+// MCP endpoint (Streamable HTTP transport, per-user auth)
+import { mcpHandler, closeMcpSessions } from './mcp';
+app.post('/mcp', mcpHandler);
+app.get('/mcp', mcpHandler);
+app.delete('/mcp', mcpHandler);
+
// Serve static files in production
if (process.env.NODE_ENV === 'production') {
const publicPath = path.join(__dirname, '../public');
- app.use(express.static(publicPath));
+ app.use(express.static(publicPath, {
+ setHeaders: (res, filePath) => {
+ // Never cache index.html so version updates are picked up immediately
+ if (filePath.endsWith('index.html')) {
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
+ }
+ },
+ }));
app.get('*', (req: Request, res: Response) => {
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.sendFile(path.join(publicPath, 'index.html'));
});
}
@@ -181,6 +236,7 @@ const PORT = process.env.PORT || 3001;
const server = app.listen(PORT, () => {
console.log(`TREK API running on port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
+ console.log(`Debug logs: ${DEBUG ? 'ENABLED' : 'disabled'}`);
if (process.env.DEMO_MODE === 'true') console.log('Demo mode: ENABLED');
if (process.env.DEMO_MODE === 'true' && process.env.NODE_ENV === 'production') {
console.warn('[SECURITY WARNING] DEMO_MODE is enabled in production! Demo credentials are publicly exposed.');
@@ -196,6 +252,7 @@ const server = app.listen(PORT, () => {
function shutdown(signal: string): void {
console.log(`\n${signal} received — shutting down gracefully...`);
scheduler.stop();
+ closeMcpSessions();
server.close(() => {
console.log('HTTP server closed');
const { closeDb } = require('./db/database');
diff --git a/server/src/mcp/index.ts b/server/src/mcp/index.ts
new file mode 100644
index 0000000..97b3d5d
--- /dev/null
+++ b/server/src/mcp/index.ts
@@ -0,0 +1,189 @@
+import { Request, Response } from 'express';
+import { randomUUID, createHash } from 'crypto';
+import jwt from 'jsonwebtoken';
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
+import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp';
+import { JWT_SECRET } from '../config';
+import { db } from '../db/database';
+import { User } from '../types';
+import { registerResources } from './resources';
+import { registerTools } from './tools';
+
+interface McpSession {
+ server: McpServer;
+ transport: StreamableHTTPServerTransport;
+ userId: number;
+ lastActivity: number;
+}
+
+const sessions = new Map();
+
+const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
+const MAX_SESSIONS_PER_USER = 5;
+const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
+const RATE_LIMIT_MAX = 60; // requests per minute per user
+
+interface RateLimitEntry {
+ count: number;
+ windowStart: number;
+}
+const rateLimitMap = new Map();
+
+function isRateLimited(userId: number): boolean {
+ const now = Date.now();
+ const entry = rateLimitMap.get(userId);
+ if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
+ rateLimitMap.set(userId, { count: 1, windowStart: now });
+ return false;
+ }
+ entry.count += 1;
+ return entry.count > RATE_LIMIT_MAX;
+}
+
+function countSessionsForUser(userId: number): number {
+ const cutoff = Date.now() - SESSION_TTL_MS;
+ let count = 0;
+ for (const session of sessions.values()) {
+ if (session.userId === userId && session.lastActivity >= cutoff) count++;
+ }
+ return count;
+}
+
+const sessionSweepInterval = setInterval(() => {
+ const cutoff = Date.now() - SESSION_TTL_MS;
+ for (const [sid, session] of sessions) {
+ if (session.lastActivity < cutoff) {
+ try { session.server.close(); } catch { /* ignore */ }
+ try { session.transport.close(); } catch { /* ignore */ }
+ sessions.delete(sid);
+ }
+ }
+ const rateCutoff = Date.now() - RATE_LIMIT_WINDOW_MS;
+ for (const [uid, entry] of rateLimitMap) {
+ if (entry.windowStart < rateCutoff) rateLimitMap.delete(uid);
+ }
+}, 10 * 60 * 1000); // sweep every 10 minutes
+
+// Prevent the interval from keeping the process alive if nothing else is running
+sessionSweepInterval.unref();
+
+function verifyToken(authHeader: string | undefined): User | null {
+ const token = authHeader && authHeader.split(' ')[1];
+ if (!token) return null;
+
+ // Long-lived MCP API token (trek_...)
+ if (token.startsWith('trek_')) {
+ const hash = createHash('sha256').update(token).digest('hex');
+ const row = db.prepare(`
+ SELECT u.id, u.username, u.email, u.role
+ FROM mcp_tokens mt
+ JOIN users u ON mt.user_id = u.id
+ WHERE mt.token_hash = ?
+ `).get(hash) as User | undefined;
+ if (row) {
+ // Update last_used_at (fire-and-forget, non-blocking)
+ db.prepare('UPDATE mcp_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?').run(hash);
+ return row;
+ }
+ return null;
+ }
+
+ // Short-lived JWT
+ try {
+ const decoded = jwt.verify(token, JWT_SECRET) as { id: number };
+ const user = db.prepare(
+ 'SELECT id, username, email, role FROM users WHERE id = ?'
+ ).get(decoded.id) as User | undefined;
+ return user || null;
+ } catch {
+ return null;
+ }
+}
+
+export async function mcpHandler(req: Request, res: Response): Promise {
+ const mcpAddon = db.prepare("SELECT enabled FROM addons WHERE id = 'mcp'").get() as { enabled: number } | undefined;
+ if (!mcpAddon || !mcpAddon.enabled) {
+ res.status(403).json({ error: 'MCP is not enabled' });
+ return;
+ }
+
+ const user = verifyToken(req.headers['authorization']);
+ if (!user) {
+ res.status(401).json({ error: 'Access token required' });
+ return;
+ }
+
+ if (isRateLimited(user.id)) {
+ res.status(429).json({ error: 'Too many requests. Please slow down.' });
+ return;
+ }
+
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
+
+ // Resume an existing session
+ if (sessionId) {
+ const session = sessions.get(sessionId);
+ if (!session) {
+ res.status(404).json({ error: 'Session not found' });
+ return;
+ }
+ if (session.userId !== user.id) {
+ res.status(403).json({ error: 'Session belongs to a different user' });
+ return;
+ }
+ session.lastActivity = Date.now();
+ await session.transport.handleRequest(req, res, req.body);
+ return;
+ }
+
+ // Only POST can initialize a new session
+ if (req.method !== 'POST') {
+ res.status(400).json({ error: 'Missing mcp-session-id header' });
+ return;
+ }
+
+ if (countSessionsForUser(user.id) >= MAX_SESSIONS_PER_USER) {
+ res.status(429).json({ error: 'Session limit reached. Close an existing session before opening a new one.' });
+ return;
+ }
+
+ // Create a new per-user MCP server and session
+ const server = new McpServer({ name: 'trek', version: '1.0.0' });
+ registerResources(server, user.id);
+ registerTools(server, user.id);
+
+ const transport = new StreamableHTTPServerTransport({
+ sessionIdGenerator: () => randomUUID(),
+ onsessioninitialized: (sid) => {
+ sessions.set(sid, { server, transport, userId: user.id, lastActivity: Date.now() });
+ },
+ onsessionclosed: (sid) => {
+ sessions.delete(sid);
+ },
+ });
+
+ await server.connect(transport);
+ await transport.handleRequest(req, res, req.body);
+}
+
+/** Terminate all active MCP sessions for a specific user (e.g. on token revocation). */
+export function revokeUserSessions(userId: number): void {
+ for (const [sid, session] of sessions) {
+ if (session.userId === userId) {
+ try { session.server.close(); } catch { /* ignore */ }
+ try { session.transport.close(); } catch { /* ignore */ }
+ sessions.delete(sid);
+ }
+ }
+}
+
+/** Close all active MCP sessions (call during graceful shutdown). */
+export function closeMcpSessions(): void {
+ clearInterval(sessionSweepInterval);
+ for (const [, session] of sessions) {
+ try { session.server.close(); } catch { /* ignore */ }
+ try { session.transport.close(); } catch { /* ignore */ }
+ }
+ sessions.clear();
+ rateLimitMap.clear();
+}
diff --git a/server/src/mcp/resources.ts b/server/src/mcp/resources.ts
new file mode 100644
index 0000000..a077419
--- /dev/null
+++ b/server/src/mcp/resources.ts
@@ -0,0 +1,304 @@
+import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp';
+import { db, canAccessTrip } from '../db/database';
+
+const TRIP_SELECT = `
+ SELECT t.*,
+ (SELECT COUNT(*) FROM days d WHERE d.trip_id = t.id) as day_count,
+ (SELECT COUNT(*) FROM places p WHERE p.trip_id = t.id) as place_count,
+ CASE WHEN t.user_id = :userId THEN 1 ELSE 0 END as is_owner,
+ u.username as owner_username,
+ (SELECT COUNT(*) FROM trip_members tm WHERE tm.trip_id = t.id) as shared_count
+ FROM trips t
+ JOIN users u ON u.id = t.user_id
+`;
+
+function parseId(value: string | string[]): number | null {
+ const n = Number(Array.isArray(value) ? value[0] : value);
+ return Number.isInteger(n) && n > 0 ? n : null;
+}
+
+function accessDenied(uri: string) {
+ return {
+ contents: [{
+ uri,
+ mimeType: 'application/json',
+ text: JSON.stringify({ error: 'Trip not found or access denied' }),
+ }],
+ };
+}
+
+function jsonContent(uri: string, data: unknown) {
+ return {
+ contents: [{
+ uri,
+ mimeType: 'application/json',
+ text: JSON.stringify(data, null, 2),
+ }],
+ };
+}
+
+export function registerResources(server: McpServer, userId: number): void {
+ // List all accessible trips
+ server.registerResource(
+ 'trips',
+ 'trek://trips',
+ { description: 'All trips the user owns or is a member of' },
+ async (uri) => {
+ const trips = db.prepare(`
+ ${TRIP_SELECT}
+ LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
+ WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) AND t.is_archived = 0
+ ORDER BY t.created_at DESC
+ `).all({ userId });
+ return jsonContent(uri.href, trips);
+ }
+ );
+
+ // Single trip detail
+ server.registerResource(
+ 'trip',
+ new ResourceTemplate('trek://trips/{tripId}', { list: undefined }),
+ { description: 'A single trip with metadata and member count' },
+ async (uri, { tripId }) => {
+ const id = parseId(tripId);
+ if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
+ const trip = db.prepare(`
+ ${TRIP_SELECT}
+ LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
+ WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL)
+ `).get({ userId, tripId: id });
+ return jsonContent(uri.href, trip);
+ }
+ );
+
+ // Days with assigned places
+ server.registerResource(
+ 'trip-days',
+ new ResourceTemplate('trek://trips/{tripId}/days', { list: undefined }),
+ { description: 'Days of a trip with their assigned places' },
+ async (uri, { tripId }) => {
+ const id = parseId(tripId);
+ if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
+
+ const days = db.prepare(
+ 'SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC'
+ ).all(id) as { id: number; day_number: number; date: string | null; title: string | null; notes: string | null }[];
+
+ const dayIds = days.map(d => d.id);
+ const assignmentsByDay: Record = {};
+
+ if (dayIds.length > 0) {
+ const placeholders = dayIds.map(() => '?').join(',');
+ const assignments = db.prepare(`
+ SELECT da.id, da.day_id, da.order_index, da.notes as assignment_notes,
+ p.id as place_id, p.name, p.address, p.lat, p.lng, p.category_id,
+ COALESCE(da.assignment_time, p.place_time) as place_time,
+ c.name as category_name, c.color as category_color, c.icon as category_icon
+ FROM day_assignments da
+ JOIN places p ON da.place_id = p.id
+ LEFT JOIN categories c ON p.category_id = c.id
+ WHERE da.day_id IN (${placeholders})
+ ORDER BY da.order_index ASC, da.created_at ASC
+ `).all(...dayIds) as (Record & { day_id: number })[];
+
+ for (const a of assignments) {
+ if (!assignmentsByDay[a.day_id]) assignmentsByDay[a.day_id] = [];
+ assignmentsByDay[a.day_id].push(a);
+ }
+ }
+
+ const result = days.map(d => ({ ...d, assignments: assignmentsByDay[d.id] || [] }));
+ return jsonContent(uri.href, result);
+ }
+ );
+
+ // Places in a trip
+ server.registerResource(
+ 'trip-places',
+ new ResourceTemplate('trek://trips/{tripId}/places', { list: undefined }),
+ { description: 'All places/POIs saved in a trip' },
+ async (uri, { tripId }) => {
+ const id = parseId(tripId);
+ if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
+ const places = db.prepare(`
+ SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
+ FROM places p
+ LEFT JOIN categories c ON p.category_id = c.id
+ WHERE p.trip_id = ?
+ ORDER BY p.created_at DESC
+ `).all(id);
+ return jsonContent(uri.href, places);
+ }
+ );
+
+ // Budget items
+ server.registerResource(
+ 'trip-budget',
+ new ResourceTemplate('trek://trips/{tripId}/budget', { list: undefined }),
+ { description: 'Budget and expense items for a trip' },
+ async (uri, { tripId }) => {
+ const id = parseId(tripId);
+ if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
+ const items = db.prepare(
+ 'SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC, created_at ASC'
+ ).all(id);
+ return jsonContent(uri.href, items);
+ }
+ );
+
+ // Packing checklist
+ server.registerResource(
+ 'trip-packing',
+ new ResourceTemplate('trek://trips/{tripId}/packing', { list: undefined }),
+ { description: 'Packing checklist for a trip' },
+ async (uri, { tripId }) => {
+ const id = parseId(tripId);
+ if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
+ const items = db.prepare(
+ 'SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC'
+ ).all(id);
+ return jsonContent(uri.href, items);
+ }
+ );
+
+ // Reservations (flights, hotels, restaurants)
+ server.registerResource(
+ 'trip-reservations',
+ new ResourceTemplate('trek://trips/{tripId}/reservations', { list: undefined }),
+ { description: 'Reservations (flights, hotels, restaurants) for a trip' },
+ async (uri, { tripId }) => {
+ const id = parseId(tripId);
+ if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
+ const reservations = db.prepare(`
+ SELECT r.*, d.day_number, p.name as place_name
+ FROM reservations r
+ LEFT JOIN days d ON r.day_id = d.id
+ LEFT JOIN places p ON r.place_id = p.id
+ WHERE r.trip_id = ?
+ ORDER BY r.reservation_time ASC, r.created_at ASC
+ `).all(id);
+ return jsonContent(uri.href, reservations);
+ }
+ );
+
+ // Day notes
+ server.registerResource(
+ 'day-notes',
+ new ResourceTemplate('trek://trips/{tripId}/days/{dayId}/notes', { list: undefined }),
+ { description: 'Notes for a specific day in a trip' },
+ async (uri, { tripId, dayId }) => {
+ const tId = parseId(tripId);
+ const dId = parseId(dayId);
+ if (tId === null || dId === null || !canAccessTrip(tId, userId)) return accessDenied(uri.href);
+ const notes = db.prepare(
+ 'SELECT * FROM day_notes WHERE day_id = ? AND trip_id = ? ORDER BY sort_order ASC, created_at ASC'
+ ).all(dId, tId);
+ return jsonContent(uri.href, notes);
+ }
+ );
+
+ // Accommodations (hotels, rentals) per trip
+ server.registerResource(
+ 'trip-accommodations',
+ new ResourceTemplate('trek://trips/{tripId}/accommodations', { list: undefined }),
+ { description: 'Accommodations (hotels, rentals) for a trip with check-in/out details' },
+ async (uri, { tripId }) => {
+ const id = parseId(tripId);
+ if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
+ const accommodations = db.prepare(`
+ SELECT da.*, p.name as place_name, p.address as place_address, p.lat, p.lng,
+ ds.day_number as start_day_number, de.day_number as end_day_number
+ FROM day_accommodations da
+ JOIN places p ON da.place_id = p.id
+ LEFT JOIN days ds ON da.start_day_id = ds.id
+ LEFT JOIN days de ON da.end_day_id = de.id
+ WHERE da.trip_id = ?
+ ORDER BY ds.day_number ASC
+ `).all(id);
+ return jsonContent(uri.href, accommodations);
+ }
+ );
+
+ // Trip members (owner + collaborators)
+ server.registerResource(
+ 'trip-members',
+ new ResourceTemplate('trek://trips/{tripId}/members', { list: undefined }),
+ { description: 'Owner and collaborators of a trip' },
+ async (uri, { tripId }) => {
+ const id = parseId(tripId);
+ if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
+ const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(id) as { user_id: number } | undefined;
+ if (!trip) return accessDenied(uri.href);
+ const owner = db.prepare('SELECT id, username, avatar FROM users WHERE id = ?').get(trip.user_id) as Record | undefined;
+ const members = db.prepare(`
+ SELECT u.id, u.username, u.avatar, tm.added_at
+ FROM trip_members tm
+ JOIN users u ON tm.user_id = u.id
+ WHERE tm.trip_id = ?
+ ORDER BY tm.added_at ASC
+ `).all(id);
+ return jsonContent(uri.href, {
+ owner: owner ? { ...owner, role: 'owner' } : null,
+ members,
+ });
+ }
+ );
+
+ // Collab notes for a trip
+ server.registerResource(
+ 'trip-collab-notes',
+ new ResourceTemplate('trek://trips/{tripId}/collab-notes', { list: undefined }),
+ { description: 'Shared collaborative notes for a trip' },
+ async (uri, { tripId }) => {
+ const id = parseId(tripId);
+ if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
+ const notes = db.prepare(`
+ SELECT cn.*, u.username
+ FROM collab_notes cn
+ JOIN users u ON cn.user_id = u.id
+ WHERE cn.trip_id = ?
+ ORDER BY cn.pinned DESC, cn.updated_at DESC
+ `).all(id);
+ return jsonContent(uri.href, notes);
+ }
+ );
+
+ // All place categories (global, no trip filter)
+ server.registerResource(
+ 'categories',
+ 'trek://categories',
+ { description: 'All available place categories (id, name, color, icon) for use when creating places' },
+ async (uri) => {
+ const categories = db.prepare(
+ 'SELECT id, name, color, icon FROM categories ORDER BY name ASC'
+ ).all();
+ return jsonContent(uri.href, categories);
+ }
+ );
+
+ // User's bucket list
+ server.registerResource(
+ 'bucket-list',
+ 'trek://bucket-list',
+ { description: 'Your personal travel bucket list' },
+ async (uri) => {
+ const items = db.prepare(
+ 'SELECT * FROM bucket_list WHERE user_id = ? ORDER BY created_at DESC'
+ ).all(userId);
+ return jsonContent(uri.href, items);
+ }
+ );
+
+ // User's visited countries
+ server.registerResource(
+ 'visited-countries',
+ 'trek://visited-countries',
+ { description: 'Countries you have marked as visited in Atlas' },
+ async (uri) => {
+ const countries = db.prepare(
+ 'SELECT country_code, created_at FROM visited_countries WHERE user_id = ? ORDER BY created_at DESC'
+ ).all(userId);
+ return jsonContent(uri.href, countries);
+ }
+ );
+}
diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts
new file mode 100644
index 0000000..8ea84e7
--- /dev/null
+++ b/server/src/mcp/tools.ts
@@ -0,0 +1,1175 @@
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
+import { z } from 'zod';
+import path from 'path';
+import fs from 'fs';
+import { db, canAccessTrip, isOwner } from '../db/database';
+import { broadcast } from '../websocket';
+
+const MS_PER_DAY = 86400000;
+const MAX_TRIP_DAYS = 90;
+
+function isDemoUser(userId: number): boolean {
+ if (process.env.DEMO_MODE !== 'true') return false;
+ const user = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined;
+ return user?.email === 'demo@nomad.app';
+}
+
+function demoDenied() {
+ return { content: [{ type: 'text' as const, text: 'Write operations are disabled in demo mode.' }], isError: true };
+}
+
+function noAccess() {
+ return { content: [{ type: 'text' as const, text: 'Trip not found or access denied.' }], isError: true };
+}
+
+function ok(data: unknown) {
+ return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
+}
+
+/** Create days for a newly created trip (fresh insert, no existing days). */
+function createDaysForNewTrip(tripId: number | bigint, startDate: string | null, endDate: string | null): void {
+ const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)');
+ if (startDate && endDate) {
+ const [sy, sm, sd] = startDate.split('-').map(Number);
+ const [ey, em, ed] = endDate.split('-').map(Number);
+ const startMs = Date.UTC(sy, sm - 1, sd);
+ const endMs = Date.UTC(ey, em - 1, ed);
+ const numDays = Math.min(Math.floor((endMs - startMs) / MS_PER_DAY) + 1, MAX_TRIP_DAYS);
+ for (let i = 0; i < numDays; i++) {
+ const d = new Date(startMs + i * MS_PER_DAY);
+ const date = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
+ insert.run(tripId, i + 1, date);
+ }
+ } else {
+ for (let i = 0; i < 7; i++) insert.run(tripId, i + 1, null);
+ }
+}
+
+export function registerTools(server: McpServer, userId: number): void {
+ // --- TRIPS ---
+
+ server.registerTool(
+ 'create_trip',
+ {
+ description: 'Create a new trip. Returns the created trip with its generated days.',
+ inputSchema: {
+ title: z.string().min(1).max(200).describe('Trip title'),
+ description: z.string().max(2000).optional().describe('Trip description'),
+ start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Start date (YYYY-MM-DD)'),
+ end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('End date (YYYY-MM-DD)'),
+ currency: z.string().length(3).optional().describe('Currency code (e.g. EUR, USD)'),
+ },
+ },
+ async ({ title, description, start_date, end_date, currency }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (start_date) {
+ const d = new Date(start_date + 'T00:00:00Z');
+ if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
+ return { content: [{ type: 'text' as const, text: 'start_date is not a valid calendar date.' }], isError: true };
+ }
+ if (end_date) {
+ const d = new Date(end_date + 'T00:00:00Z');
+ if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
+ return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true };
+ }
+ if (start_date && end_date && new Date(end_date) < new Date(start_date)) {
+ return { content: [{ type: 'text' as const, text: 'End date must be after start date.' }], isError: true };
+ }
+ const trip = db.transaction(() => {
+ const result = db.prepare(
+ 'INSERT INTO trips (user_id, title, description, start_date, end_date, currency) VALUES (?, ?, ?, ?, ?, ?)'
+ ).run(userId, title, description || null, start_date || null, end_date || null, currency || 'EUR');
+ const tripId = result.lastInsertRowid as number;
+ createDaysForNewTrip(tripId, start_date || null, end_date || null);
+ return db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId);
+ })();
+ return ok({ trip });
+ }
+ );
+
+ server.registerTool(
+ 'update_trip',
+ {
+ description: 'Update an existing trip\'s details.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ title: z.string().min(1).max(200).optional(),
+ description: z.string().max(2000).optional(),
+ start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
+ end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
+ currency: z.string().length(3).optional(),
+ },
+ },
+ async ({ tripId, title, description, start_date, end_date, currency }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+ if (start_date) {
+ const d = new Date(start_date + 'T00:00:00Z');
+ if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
+ return { content: [{ type: 'text' as const, text: 'start_date is not a valid calendar date.' }], isError: true };
+ }
+ if (end_date) {
+ const d = new Date(end_date + 'T00:00:00Z');
+ if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
+ return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true };
+ }
+ const existing = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Record & { title: string; description: string; start_date: string; end_date: string; currency: string } | undefined;
+ if (!existing) return noAccess();
+ db.prepare(
+ 'UPDATE trips SET title = ?, description = ?, start_date = ?, end_date = ?, currency = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
+ ).run(
+ title ?? existing.title,
+ description !== undefined ? description : existing.description,
+ start_date !== undefined ? start_date : existing.start_date,
+ end_date !== undefined ? end_date : existing.end_date,
+ currency ?? existing.currency,
+ tripId
+ );
+ const updated = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId);
+ broadcast(tripId, 'trip:updated', { trip: updated });
+ return ok({ trip: updated });
+ }
+ );
+
+ server.registerTool(
+ 'delete_trip',
+ {
+ description: 'Delete a trip. Only the trip owner can delete it.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ },
+ },
+ async ({ tripId }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!isOwner(tripId, userId)) return noAccess();
+ db.prepare('DELETE FROM trips WHERE id = ?').run(tripId);
+ return ok({ success: true, tripId });
+ }
+ );
+
+ server.registerTool(
+ 'list_trips',
+ {
+ description: 'List all trips the current user owns or is a member of. Use this for trip discovery before calling get_trip_summary.',
+ inputSchema: {
+ include_archived: z.boolean().optional().describe('Include archived trips (default false)'),
+ },
+ },
+ async ({ include_archived }) => {
+ const trips = db.prepare(`
+ SELECT t.*, u.username as owner_username,
+ (SELECT COUNT(*) FROM days d WHERE d.trip_id = t.id) as day_count,
+ (SELECT COUNT(*) FROM places p WHERE p.trip_id = t.id) as place_count,
+ CASE WHEN t.user_id = ? THEN 1 ELSE 0 END as is_owner
+ FROM trips t
+ JOIN users u ON u.id = t.user_id
+ LEFT JOIN trip_members tm ON tm.trip_id = t.id AND tm.user_id = ?
+ WHERE (t.user_id = ? OR tm.user_id IS NOT NULL)
+ AND (? = 1 OR t.is_archived = 0)
+ ORDER BY t.updated_at DESC
+ `).all(userId, userId, userId, include_archived ? 1 : 0);
+ return ok({ trips });
+ }
+ );
+
+ // --- PLACES ---
+
+ server.registerTool(
+ 'create_place',
+ {
+ description: 'Add a new place/POI to a trip.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ name: z.string().min(1).max(200),
+ description: z.string().max(2000).optional(),
+ lat: z.number().optional(),
+ lng: z.number().optional(),
+ address: z.string().max(500).optional(),
+ category_id: z.number().int().positive().optional(),
+ notes: z.string().max(2000).optional(),
+ website: z.string().max(500).optional(),
+ phone: z.string().max(50).optional(),
+ },
+ },
+ async ({ tripId, name, description, lat, lng, address, category_id, notes, website, phone }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+ const result = db.prepare(`
+ INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, notes, website, phone, transport_mode)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `).run(tripId, name, description || null, lat ?? null, lng ?? null, address || null, category_id || null, notes || null, website || null, phone || null, 'walking');
+ const place = db.prepare('SELECT * FROM places WHERE id = ?').get(result.lastInsertRowid);
+ broadcast(tripId, 'place:created', { place });
+ return ok({ place });
+ }
+ );
+
+ server.registerTool(
+ 'update_place',
+ {
+ description: 'Update an existing place in a trip.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ placeId: z.number().int().positive(),
+ name: z.string().min(1).max(200).optional(),
+ description: z.string().max(2000).optional(),
+ lat: z.number().optional(),
+ lng: z.number().optional(),
+ address: z.string().max(500).optional(),
+ notes: z.string().max(2000).optional(),
+ website: z.string().max(500).optional(),
+ phone: z.string().max(50).optional(),
+ },
+ },
+ async ({ tripId, placeId, name, description, lat, lng, address, notes, website, phone }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+ const existing = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Record | undefined;
+ if (!existing) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
+ db.prepare(`
+ UPDATE places SET
+ name = ?, description = ?, lat = ?, lng = ?, address = ?, notes = ?, website = ?, phone = ?,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ `).run(
+ name ?? existing.name,
+ description !== undefined ? description : existing.description,
+ lat !== undefined ? lat : existing.lat,
+ lng !== undefined ? lng : existing.lng,
+ address !== undefined ? address : existing.address,
+ notes !== undefined ? notes : existing.notes,
+ website !== undefined ? website : existing.website,
+ phone !== undefined ? phone : existing.phone,
+ placeId
+ );
+ const place = db.prepare('SELECT * FROM places WHERE id = ?').get(placeId);
+ broadcast(tripId, 'place:updated', { place });
+ return ok({ place });
+ }
+ );
+
+ server.registerTool(
+ 'delete_place',
+ {
+ description: 'Delete a place from a trip.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ placeId: z.number().int().positive(),
+ },
+ },
+ async ({ tripId, placeId }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+ const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId);
+ if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
+ db.prepare('DELETE FROM places WHERE id = ?').run(placeId);
+ broadcast(tripId, 'place:deleted', { placeId });
+ return ok({ success: true });
+ }
+ );
+
+ // --- ASSIGNMENTS ---
+
+ server.registerTool(
+ 'assign_place_to_day',
+ {
+ description: 'Assign a place to a specific day in a trip.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ dayId: z.number().int().positive(),
+ placeId: z.number().int().positive(),
+ notes: z.string().max(500).optional(),
+ },
+ },
+ async ({ tripId, dayId, placeId, notes }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+ const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
+ if (!day) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
+ const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId);
+ if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
+ const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?').get(dayId) as { max: number | null };
+ const orderIndex = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
+ const result = db.prepare(
+ 'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, ?, ?)'
+ ).run(dayId, placeId, orderIndex, notes || null);
+ const assignment = db.prepare(`
+ SELECT da.*, p.name as place_name, p.address, p.lat, p.lng
+ FROM day_assignments da JOIN places p ON da.place_id = p.id
+ WHERE da.id = ?
+ `).get(result.lastInsertRowid);
+ broadcast(tripId, 'assignment:created', { assignment });
+ return ok({ assignment });
+ }
+ );
+
+ server.registerTool(
+ 'unassign_place',
+ {
+ description: 'Remove a place assignment from a day.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ dayId: z.number().int().positive(),
+ assignmentId: z.number().int().positive(),
+ },
+ },
+ async ({ tripId, dayId, assignmentId }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+ const assignment = db.prepare(
+ 'SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND da.day_id = ? AND d.trip_id = ?'
+ ).get(assignmentId, dayId, tripId);
+ if (!assignment) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
+ db.prepare('DELETE FROM day_assignments WHERE id = ?').run(assignmentId);
+ broadcast(tripId, 'assignment:deleted', { assignmentId, dayId });
+ return ok({ success: true });
+ }
+ );
+
+ // --- BUDGET ---
+
+ server.registerTool(
+ 'create_budget_item',
+ {
+ description: 'Add a budget/expense item to a trip.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ name: z.string().min(1).max(200),
+ category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
+ total_price: z.number().nonnegative(),
+ note: z.string().max(500).optional(),
+ },
+ },
+ async ({ tripId, name, category, total_price, note }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+ const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?').get(tripId) as { max: number | null };
+ const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
+ const result = db.prepare(
+ 'INSERT INTO budget_items (trip_id, category, name, total_price, note, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
+ ).run(tripId, category || 'Other', name, total_price, note || null, sortOrder);
+ const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid);
+ broadcast(tripId, 'budget:created', { item });
+ return ok({ item });
+ }
+ );
+
+ server.registerTool(
+ 'delete_budget_item',
+ {
+ description: 'Delete a budget item from a trip.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ itemId: z.number().int().positive(),
+ },
+ },
+ async ({ tripId, itemId }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+ const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(itemId, tripId);
+ if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
+ db.prepare('DELETE FROM budget_items WHERE id = ?').run(itemId);
+ broadcast(tripId, 'budget:deleted', { itemId });
+ return ok({ success: true });
+ }
+ );
+
+ // --- PACKING ---
+
+ server.registerTool(
+ 'create_packing_item',
+ {
+ description: 'Add an item to the packing checklist for a trip.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ name: z.string().min(1).max(200),
+ category: z.string().max(100).optional().describe('Packing category (e.g. Clothes, Electronics)'),
+ },
+ },
+ async ({ tripId, name, category }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+ const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null };
+ const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
+ const result = db.prepare(
+ 'INSERT INTO packing_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, ?, ?, ?)'
+ ).run(tripId, name, 0, category || 'General', sortOrder);
+ const item = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid);
+ broadcast(tripId, 'packing:created', { item });
+ return ok({ item });
+ }
+ );
+
+ server.registerTool(
+ 'toggle_packing_item',
+ {
+ description: 'Check or uncheck a packing item.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ itemId: z.number().int().positive(),
+ checked: z.boolean(),
+ },
+ },
+ async ({ tripId, itemId, checked }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+ const item = db.prepare('SELECT id FROM packing_items WHERE id = ? AND trip_id = ?').get(itemId, tripId);
+ if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
+ db.prepare('UPDATE packing_items SET checked = ? WHERE id = ?').run(checked ? 1 : 0, itemId);
+ const updated = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(itemId);
+ broadcast(tripId, 'packing:updated', { item: updated });
+ return ok({ item: updated });
+ }
+ );
+
+ server.registerTool(
+ 'delete_packing_item',
+ {
+ description: 'Remove an item from the packing checklist.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ itemId: z.number().int().positive(),
+ },
+ },
+ async ({ tripId, itemId }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+ const item = db.prepare('SELECT id FROM packing_items WHERE id = ? AND trip_id = ?').get(itemId, tripId);
+ if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
+ db.prepare('DELETE FROM packing_items WHERE id = ?').run(itemId);
+ broadcast(tripId, 'packing:deleted', { itemId });
+ return ok({ success: true });
+ }
+ );
+
+ // --- RESERVATIONS ---
+
+ server.registerTool(
+ 'create_reservation',
+ {
+ description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/train/car/cruise/event/tour/activity/other → use assignment_id; flight → no linking.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ title: z.string().min(1).max(200),
+ type: z.enum(['flight', 'hotel', 'restaurant', 'train', 'car', 'cruise', 'event', 'tour', 'activity', 'other']),
+ reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'),
+ location: z.string().max(500).optional(),
+ confirmation_number: z.string().max(100).optional(),
+ notes: z.string().max(1000).optional(),
+ day_id: z.number().int().positive().optional(),
+ place_id: z.number().int().positive().optional().describe('Hotel place to link (hotel type only)'),
+ start_day_id: z.number().int().positive().optional().describe('Check-in day (hotel type only; requires place_id and end_day_id)'),
+ end_day_id: z.number().int().positive().optional().describe('Check-out day (hotel type only; requires place_id and start_day_id)'),
+ assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'),
+ },
+ },
+ async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, assignment_id }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+
+ // Validate that all referenced IDs belong to this trip
+ if (day_id) {
+ if (!db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(day_id, tripId))
+ return { content: [{ type: 'text' as const, text: 'day_id does not belong to this trip.' }], isError: true };
+ }
+ if (place_id) {
+ if (!db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId))
+ return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
+ }
+ if (start_day_id) {
+ if (!db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(start_day_id, tripId))
+ return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true };
+ }
+ if (end_day_id) {
+ if (!db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(end_day_id, tripId))
+ return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
+ }
+ if (assignment_id) {
+ if (!db.prepare('SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND d.trip_id = ?').get(assignment_id, tripId))
+ return { content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }], isError: true };
+ }
+
+ const reservation = db.transaction(() => {
+ let accommodationId: number | null = null;
+ if (type === 'hotel' && place_id && start_day_id && end_day_id) {
+ const accResult = db.prepare(
+ 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, confirmation) VALUES (?, ?, ?, ?, ?)'
+ ).run(tripId, place_id, start_day_id, end_day_id, confirmation_number || null);
+ accommodationId = accResult.lastInsertRowid as number;
+ }
+ const result = db.prepare(`
+ INSERT INTO reservations (trip_id, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, assignment_id, accommodation_id, status)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `).run(tripId, title, type, reservation_time || null, location || null, confirmation_number || null, notes || null, day_id || null, place_id || null, assignment_id || null, accommodationId, 'pending');
+ return db.prepare('SELECT * FROM reservations WHERE id = ?').get(result.lastInsertRowid);
+ })();
+
+ if (type === 'hotel' && place_id && start_day_id && end_day_id) {
+ broadcast(tripId, 'accommodation:created', {});
+ }
+ broadcast(tripId, 'reservation:created', { reservation });
+ return ok({ reservation });
+ }
+ );
+
+ server.registerTool(
+ 'delete_reservation',
+ {
+ description: 'Delete a reservation from a trip.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ reservationId: z.number().int().positive(),
+ },
+ },
+ async ({ tripId, reservationId }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+ const reservation = db.prepare('SELECT id, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?').get(reservationId, tripId) as { id: number; accommodation_id: number | null } | undefined;
+ if (!reservation) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
+ db.transaction(() => {
+ if (reservation.accommodation_id) {
+ db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(reservation.accommodation_id);
+ }
+ db.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId);
+ })();
+ if (reservation.accommodation_id) {
+ broadcast(tripId, 'accommodation:deleted', { accommodationId: reservation.accommodation_id });
+ }
+ broadcast(tripId, 'reservation:deleted', { reservationId });
+ return ok({ success: true });
+ }
+ );
+
+ server.registerTool(
+ 'link_hotel_accommodation',
+ {
+ description: 'Set or update the check-in/check-out day links for a hotel reservation. Creates or updates the accommodation record that ties the reservation to a place and a date range. Use the day IDs from get_trip_summary.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ reservationId: z.number().int().positive(),
+ place_id: z.number().int().positive().describe('The hotel place to link'),
+ start_day_id: z.number().int().positive().describe('Check-in day ID'),
+ end_day_id: z.number().int().positive().describe('Check-out day ID'),
+ },
+ },
+ async ({ tripId, reservationId, place_id, start_day_id, end_day_id }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+ const reservation = db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(reservationId, tripId) as Record | undefined;
+ if (!reservation) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
+ if (reservation.type !== 'hotel') return { content: [{ type: 'text' as const, text: 'Reservation is not of type hotel.' }], isError: true };
+
+ if (!db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId))
+ return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
+ if (!db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(start_day_id, tripId))
+ return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true };
+ if (!db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(end_day_id, tripId))
+ return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
+
+ let accommodationId = reservation.accommodation_id as number | null;
+ const isNewAccommodation = !accommodationId;
+ db.transaction(() => {
+ if (accommodationId) {
+ db.prepare('UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ? WHERE id = ?')
+ .run(place_id, start_day_id, end_day_id, accommodationId);
+ } else {
+ const accResult = db.prepare(
+ 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, confirmation) VALUES (?, ?, ?, ?, ?)'
+ ).run(tripId, place_id, start_day_id, end_day_id, reservation.confirmation_number || null);
+ accommodationId = accResult.lastInsertRowid as number;
+ }
+ db.prepare('UPDATE reservations SET place_id = ?, accommodation_id = ? WHERE id = ?')
+ .run(place_id, accommodationId, reservationId);
+ })();
+ broadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {});
+ const updated = db.prepare('SELECT * FROM reservations WHERE id = ?').get(reservationId);
+ broadcast(tripId, 'reservation:updated', { reservation: updated });
+ return ok({ reservation: updated, accommodation_id: accommodationId });
+ }
+ );
+
+ // --- DAYS ---
+
+ server.registerTool(
+ 'update_assignment_time',
+ {
+ description: 'Set the start and/or end time for a place assignment on a day (e.g. "09:00", "11:30"). Pass null to clear a time.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ assignmentId: z.number().int().positive(),
+ place_time: z.string().max(50).nullable().optional().describe('Start time (e.g. "09:00"), or null to clear'),
+ end_time: z.string().max(50).nullable().optional().describe('End time (e.g. "11:00"), or null to clear'),
+ },
+ },
+ async ({ tripId, assignmentId, place_time, end_time }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+ const assignment = db.prepare(`
+ SELECT da.* FROM day_assignments da
+ JOIN days d ON da.day_id = d.id
+ WHERE da.id = ? AND d.trip_id = ?
+ `).get(assignmentId, tripId) as Record | undefined;
+ if (!assignment) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
+ db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?')
+ .run(
+ place_time !== undefined ? place_time : assignment.assignment_time,
+ end_time !== undefined ? end_time : assignment.assignment_end_time,
+ assignmentId
+ );
+ const updated = db.prepare(`
+ SELECT da.id, da.day_id, da.order_index, da.notes as assignment_notes,
+ da.assignment_time, da.assignment_end_time,
+ p.id as place_id, p.name, p.address
+ FROM day_assignments da
+ JOIN places p ON da.place_id = p.id
+ WHERE da.id = ?
+ `).get(assignmentId);
+ broadcast(tripId, 'assignment:updated', { assignment: updated });
+ return ok({ assignment: updated });
+ }
+ );
+
+ server.registerTool(
+ 'update_day',
+ {
+ description: 'Set the title of a day in a trip (e.g. "Arrival in Paris", "Free day").',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ dayId: z.number().int().positive(),
+ title: z.string().max(200).nullable().describe('Day title, or null to clear it'),
+ },
+ },
+ async ({ tripId, dayId, title }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+ const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
+ if (!day) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
+ db.prepare('UPDATE days SET title = ? WHERE id = ?').run(title, dayId);
+ const updated = db.prepare('SELECT * FROM days WHERE id = ?').get(dayId);
+ broadcast(tripId, 'day:updated', { day: updated });
+ return ok({ day: updated });
+ }
+ );
+
+ // --- RESERVATIONS (update) ---
+
+ server.registerTool(
+ 'update_reservation',
+ {
+ description: 'Update an existing reservation in a trip. Use status "confirmed" to confirm a pending recommendation, or "pending" to revert it. Linking: hotel → use place_id to link to an accommodation place; restaurant/train/car/cruise/event/tour/activity/other → use assignment_id to link to a day assignment; flight → no linking.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ reservationId: z.number().int().positive(),
+ title: z.string().min(1).max(200).optional(),
+ type: z.enum(['flight', 'hotel', 'restaurant', 'train', 'car', 'cruise', 'event', 'tour', 'activity', 'other']).optional(),
+ reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'),
+ location: z.string().max(500).optional(),
+ confirmation_number: z.string().max(100).optional(),
+ notes: z.string().max(1000).optional(),
+ status: z.enum(['pending', 'confirmed', 'cancelled']).optional(),
+ place_id: z.number().int().positive().nullable().optional().describe('Link to a place (use for hotel type), or null to unlink'),
+ assignment_id: z.number().int().positive().nullable().optional().describe('Link to a day assignment (use for restaurant, train, car, cruise, event, tour, activity, other), or null to unlink'),
+ },
+ },
+ async ({ tripId, reservationId, title, type, reservation_time, location, confirmation_number, notes, status, place_id, assignment_id }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+ const existing = db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(reservationId, tripId) as Record | undefined;
+ if (!existing) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
+
+ if (place_id != null) {
+ if (!db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId))
+ return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
+ }
+ if (assignment_id != null) {
+ if (!db.prepare('SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND d.trip_id = ?').get(assignment_id, tripId))
+ return { content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }], isError: true };
+ }
+
+ db.prepare(`
+ UPDATE reservations SET
+ title = ?, type = ?, reservation_time = ?, location = ?,
+ confirmation_number = ?, notes = ?, status = ?,
+ place_id = ?, assignment_id = ?
+ WHERE id = ?
+ `).run(
+ title ?? existing.title,
+ type ?? existing.type,
+ reservation_time !== undefined ? reservation_time : existing.reservation_time,
+ location !== undefined ? location : existing.location,
+ confirmation_number !== undefined ? confirmation_number : existing.confirmation_number,
+ notes !== undefined ? notes : existing.notes,
+ status ?? existing.status,
+ place_id !== undefined ? place_id : existing.place_id,
+ assignment_id !== undefined ? assignment_id : existing.assignment_id,
+ reservationId
+ );
+ const updated = db.prepare('SELECT * FROM reservations WHERE id = ?').get(reservationId);
+ broadcast(tripId, 'reservation:updated', { reservation: updated });
+ return ok({ reservation: updated });
+ }
+ );
+
+ // --- BUDGET (update) ---
+
+ server.registerTool(
+ 'update_budget_item',
+ {
+ description: 'Update an existing budget/expense item in a trip.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ itemId: z.number().int().positive(),
+ name: z.string().min(1).max(200).optional(),
+ category: z.string().max(100).optional(),
+ total_price: z.number().nonnegative().optional(),
+ persons: z.number().int().positive().nullable().optional(),
+ days: z.number().int().positive().nullable().optional(),
+ note: z.string().max(500).nullable().optional(),
+ },
+ },
+ async ({ tripId, itemId, name, category, total_price, persons, days, note }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+ const existing = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(itemId, tripId) as Record | undefined;
+ if (!existing) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
+ db.prepare(`
+ UPDATE budget_items SET
+ name = ?, category = ?, total_price = ?, persons = ?, days = ?, note = ?
+ WHERE id = ?
+ `).run(
+ name ?? existing.name,
+ category ?? existing.category,
+ total_price !== undefined ? total_price : existing.total_price,
+ persons !== undefined ? persons : existing.persons,
+ days !== undefined ? days : existing.days,
+ note !== undefined ? note : existing.note,
+ itemId
+ );
+ const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(itemId);
+ broadcast(tripId, 'budget:updated', { item: updated });
+ return ok({ item: updated });
+ }
+ );
+
+ // --- PACKING (update) ---
+
+ server.registerTool(
+ 'update_packing_item',
+ {
+ description: 'Rename a packing item or change its category.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ itemId: z.number().int().positive(),
+ name: z.string().min(1).max(200).optional(),
+ category: z.string().max(100).optional(),
+ },
+ },
+ async ({ tripId, itemId, name, category }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+ const existing = db.prepare('SELECT * FROM packing_items WHERE id = ? AND trip_id = ?').get(itemId, tripId) as Record | undefined;
+ if (!existing) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
+ db.prepare('UPDATE packing_items SET name = ?, category = ? WHERE id = ?').run(
+ name ?? existing.name,
+ category ?? existing.category,
+ itemId
+ );
+ const updated = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(itemId);
+ broadcast(tripId, 'packing:updated', { item: updated });
+ return ok({ item: updated });
+ }
+ );
+
+ // --- REORDER ---
+
+ server.registerTool(
+ 'reorder_day_assignments',
+ {
+ description: 'Reorder places within a day by providing the assignment IDs in the desired order.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ dayId: z.number().int().positive(),
+ assignmentIds: z.array(z.number().int().positive()).min(1).max(200).describe('Assignment IDs in desired display order'),
+ },
+ },
+ async ({ tripId, dayId, assignmentIds }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+ const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
+ if (!day) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
+ const update = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ? AND day_id = ?');
+ const updateMany = db.transaction((ids: number[]) => {
+ ids.forEach((id, index) => update.run(index, id, dayId));
+ });
+ updateMany(assignmentIds);
+ broadcast(tripId, 'assignment:reordered', { dayId, assignmentIds });
+ return ok({ success: true, dayId, order: assignmentIds });
+ }
+ );
+
+ // --- TRIP SUMMARY ---
+
+ server.registerTool(
+ 'get_trip_summary',
+ {
+ description: 'Get a full denormalized summary of a trip in a single call: metadata, members, days with assignments and notes, accommodations, budget totals, packing stats, reservations, and collab notes. Use this as a context loader before planning or modifying a trip.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ },
+ },
+ async ({ tripId }) => {
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+
+ const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Record | undefined;
+ if (!trip) return noAccess();
+
+ // Members
+ const owner = db.prepare('SELECT id, username, avatar FROM users WHERE id = ?').get(trip.user_id as number);
+ const members = db.prepare(`
+ SELECT u.id, u.username, u.avatar, tm.added_at
+ FROM trip_members tm JOIN users u ON tm.user_id = u.id
+ WHERE tm.trip_id = ?
+ `).all(tripId);
+
+ // Days with assignments
+ const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as (Record & { id: number })[];
+ const dayIds = days.map(d => d.id);
+ const assignmentsByDay: Record = {};
+ if (dayIds.length > 0) {
+ const placeholders = dayIds.map(() => '?').join(',');
+ const assignments = db.prepare(`
+ SELECT da.id, da.day_id, da.order_index, da.notes as assignment_notes,
+ p.id as place_id, p.name, p.address, p.lat, p.lng,
+ COALESCE(da.assignment_time, p.place_time) as place_time,
+ c.name as category_name, c.icon as category_icon
+ FROM day_assignments da
+ JOIN places p ON da.place_id = p.id
+ LEFT JOIN categories c ON p.category_id = c.id
+ WHERE da.day_id IN (${placeholders})
+ ORDER BY da.order_index ASC
+ `).all(...dayIds) as (Record & { day_id: number })[];
+ for (const a of assignments) {
+ if (!assignmentsByDay[a.day_id]) assignmentsByDay[a.day_id] = [];
+ assignmentsByDay[a.day_id].push(a);
+ }
+ }
+ // Day notes
+ const dayNotesByDay: Record = {};
+ if (dayIds.length > 0) {
+ const placeholders = dayIds.map(() => '?').join(',');
+ const dayNotes = db.prepare(`
+ SELECT * FROM day_notes WHERE day_id IN (${placeholders}) ORDER BY sort_order ASC
+ `).all(...dayIds) as (Record & { day_id: number })[];
+ for (const n of dayNotes) {
+ if (!dayNotesByDay[n.day_id]) dayNotesByDay[n.day_id] = [];
+ dayNotesByDay[n.day_id].push(n);
+ }
+ }
+
+ const daysWithAssignments = days.map(d => ({
+ ...d,
+ assignments: assignmentsByDay[d.id] || [],
+ notes: dayNotesByDay[d.id] || [],
+ }));
+
+ // Accommodations
+ const accommodations = db.prepare(`
+ SELECT da.*, p.name as place_name, ds.day_number as start_day_number, de.day_number as end_day_number
+ FROM day_accommodations da
+ JOIN places p ON da.place_id = p.id
+ LEFT JOIN days ds ON da.start_day_id = ds.id
+ LEFT JOIN days de ON da.end_day_id = de.id
+ WHERE da.trip_id = ?
+ ORDER BY ds.day_number ASC
+ `).all(tripId);
+
+ // Budget summary
+ const budgetStats = db.prepare(`
+ SELECT COUNT(*) as item_count, COALESCE(SUM(total_price), 0) as total
+ FROM budget_items WHERE trip_id = ?
+ `).get(tripId) as { item_count: number; total: number };
+
+ // Packing summary
+ const packingStats = db.prepare(`
+ SELECT COUNT(*) as total, SUM(CASE WHEN checked = 1 THEN 1 ELSE 0 END) as checked
+ FROM packing_items WHERE trip_id = ?
+ `).get(tripId) as { total: number; checked: number };
+
+ // Upcoming reservations (all, sorted by time)
+ const reservations = db.prepare(`
+ SELECT r.*, d.day_number
+ FROM reservations r
+ LEFT JOIN days d ON r.day_id = d.id
+ WHERE r.trip_id = ?
+ ORDER BY r.reservation_time ASC, r.created_at ASC
+ `).all(tripId);
+
+ // Collab notes
+ const collabNotes = db.prepare(
+ 'SELECT * FROM collab_notes WHERE trip_id = ? ORDER BY pinned DESC, updated_at DESC'
+ ).all(tripId);
+
+ return ok({
+ trip,
+ members: { owner, collaborators: members },
+ days: daysWithAssignments,
+ accommodations,
+ budget: { ...budgetStats, currency: trip.currency },
+ packing: packingStats,
+ reservations,
+ collab_notes: collabNotes,
+ });
+ }
+ );
+
+ // --- BUCKET LIST ---
+
+ server.registerTool(
+ 'create_bucket_list_item',
+ {
+ description: 'Add a destination to your personal travel bucket list.',
+ inputSchema: {
+ name: z.string().min(1).max(200).describe('Destination or experience name'),
+ lat: z.number().optional(),
+ lng: z.number().optional(),
+ country_code: z.string().length(2).toUpperCase().optional().describe('ISO 3166-1 alpha-2 country code'),
+ notes: z.string().max(1000).optional(),
+ },
+ },
+ async ({ name, lat, lng, country_code, notes }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ const result = db.prepare(
+ 'INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes) VALUES (?, ?, ?, ?, ?, ?)'
+ ).run(userId, name, lat ?? null, lng ?? null, country_code || null, notes || null);
+ const item = db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(result.lastInsertRowid);
+ return ok({ item });
+ }
+ );
+
+ server.registerTool(
+ 'delete_bucket_list_item',
+ {
+ description: 'Remove an item from your travel bucket list.',
+ inputSchema: {
+ itemId: z.number().int().positive(),
+ },
+ },
+ async ({ itemId }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ const item = db.prepare('SELECT id FROM bucket_list WHERE id = ? AND user_id = ?').get(itemId, userId);
+ if (!item) return { content: [{ type: 'text' as const, text: 'Bucket list item not found.' }], isError: true };
+ db.prepare('DELETE FROM bucket_list WHERE id = ?').run(itemId);
+ return ok({ success: true });
+ }
+ );
+
+ // --- ATLAS ---
+
+ server.registerTool(
+ 'mark_country_visited',
+ {
+ description: 'Mark a country as visited in your Atlas.',
+ inputSchema: {
+ country_code: z.string().length(2).toUpperCase().describe('ISO 3166-1 alpha-2 country code (e.g. "FR", "JP")'),
+ },
+ },
+ async ({ country_code }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, country_code.toUpperCase());
+ return ok({ success: true, country_code: country_code.toUpperCase() });
+ }
+ );
+
+ server.registerTool(
+ 'unmark_country_visited',
+ {
+ description: 'Remove a country from your visited countries in Atlas.',
+ inputSchema: {
+ country_code: z.string().length(2).toUpperCase().describe('ISO 3166-1 alpha-2 country code'),
+ },
+ },
+ async ({ country_code }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(userId, country_code.toUpperCase());
+ return ok({ success: true, country_code: country_code.toUpperCase() });
+ }
+ );
+
+ // --- COLLAB NOTES ---
+
+ server.registerTool(
+ 'create_collab_note',
+ {
+ description: 'Create a shared collaborative note on a trip (visible to all trip members in the Collab tab).',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ title: z.string().min(1).max(200),
+ content: z.string().max(10000).optional(),
+ category: z.string().max(100).optional().describe('Note category (e.g. "Ideas", "To-do", "General")'),
+ color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('Hex color for the note card'),
+ },
+ },
+ async ({ tripId, title, content, category, color }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+ const result = db.prepare(`
+ INSERT INTO collab_notes (trip_id, user_id, title, content, category, color)
+ VALUES (?, ?, ?, ?, ?, ?)
+ `).run(tripId, userId, title, content || null, category || 'General', color || '#6366f1');
+ const note = db.prepare('SELECT * FROM collab_notes WHERE id = ?').get(result.lastInsertRowid);
+ broadcast(tripId, 'collab:note:created', { note });
+ return ok({ note });
+ }
+ );
+
+ server.registerTool(
+ 'update_collab_note',
+ {
+ description: 'Edit an existing collaborative note on a trip.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ noteId: z.number().int().positive(),
+ title: z.string().min(1).max(200).optional(),
+ content: z.string().max(10000).optional(),
+ category: z.string().max(100).optional(),
+ color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('Hex color for the note card'),
+ pinned: z.boolean().optional().describe('Pin the note to the top'),
+ },
+ },
+ async ({ tripId, noteId, title, content, category, color, pinned }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+ const existing = db.prepare('SELECT * FROM collab_notes WHERE id = ? AND trip_id = ?').get(noteId, tripId);
+ if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
+ db.prepare(`
+ UPDATE collab_notes SET
+ title = CASE WHEN ? THEN ? ELSE title END,
+ content = CASE WHEN ? THEN ? ELSE content END,
+ category = CASE WHEN ? THEN ? ELSE category END,
+ color = CASE WHEN ? THEN ? ELSE color END,
+ pinned = CASE WHEN ? THEN ? ELSE pinned END,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ `).run(
+ title !== undefined ? 1 : 0, title !== undefined ? title : null,
+ content !== undefined ? 1 : 0, content !== undefined ? content : null,
+ category !== undefined ? 1 : 0, category !== undefined ? category : null,
+ color !== undefined ? 1 : 0, color !== undefined ? color : null,
+ pinned !== undefined ? 1 : 0, pinned !== undefined ? (pinned ? 1 : 0) : null,
+ noteId
+ );
+ const note = db.prepare('SELECT * FROM collab_notes WHERE id = ?').get(noteId);
+ broadcast(tripId, 'collab:note:updated', { note });
+ return ok({ note });
+ }
+ );
+
+ server.registerTool(
+ 'delete_collab_note',
+ {
+ description: 'Delete a collaborative note from a trip.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ noteId: z.number().int().positive(),
+ },
+ },
+ async ({ tripId, noteId }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+ const existing = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(noteId, tripId);
+ if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
+ const noteFiles = db.prepare('SELECT filename FROM trip_files WHERE note_id = ?').all(noteId) as { filename: string }[];
+ const uploadsDir = path.resolve(__dirname, '../../uploads');
+ for (const f of noteFiles) {
+ const resolved = path.resolve(path.join(uploadsDir, 'files', f.filename));
+ if (!resolved.startsWith(uploadsDir)) continue;
+ try { fs.unlinkSync(resolved); } catch {}
+ }
+ db.transaction(() => {
+ db.prepare('DELETE FROM trip_files WHERE note_id = ?').run(noteId);
+ db.prepare('DELETE FROM collab_notes WHERE id = ?').run(noteId);
+ })();
+ broadcast(tripId, 'collab:note:deleted', { noteId });
+ return ok({ success: true });
+ }
+ );
+
+ // --- DAY NOTES ---
+
+ server.registerTool(
+ 'create_day_note',
+ {
+ description: 'Add a note to a specific day in a trip.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ dayId: z.number().int().positive(),
+ text: z.string().min(1).max(500),
+ time: z.string().max(150).optional().describe('Time label (e.g. "09:00" or "Morning")'),
+ icon: z.string().optional().describe('Emoji icon for the note'),
+ },
+ },
+ async ({ tripId, dayId, text, time, icon }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+ const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
+ if (!day) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
+ const result = db.prepare(
+ 'INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
+ ).run(dayId, tripId, text.trim(), time || null, icon || '📝', 9999);
+ const note = db.prepare('SELECT * FROM day_notes WHERE id = ?').get(result.lastInsertRowid);
+ broadcast(tripId, 'dayNote:created', { dayId, note });
+ return ok({ note });
+ }
+ );
+
+ server.registerTool(
+ 'update_day_note',
+ {
+ description: 'Edit an existing note on a specific day.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ dayId: z.number().int().positive(),
+ noteId: z.number().int().positive(),
+ text: z.string().min(1).max(500).optional(),
+ time: z.string().max(150).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
+ icon: z.string().optional().describe('Emoji icon for the note'),
+ },
+ },
+ async ({ tripId, dayId, noteId, text, time, icon }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+ const existing = db.prepare('SELECT * FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(noteId, dayId, tripId) as Record | undefined;
+ if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
+ db.prepare('UPDATE day_notes SET text = ?, time = ?, icon = ? WHERE id = ?').run(
+ text !== undefined ? text.trim() : existing.text,
+ time !== undefined ? time : existing.time,
+ icon ?? existing.icon,
+ noteId
+ );
+ const updated = db.prepare('SELECT * FROM day_notes WHERE id = ?').get(noteId);
+ broadcast(tripId, 'dayNote:updated', { dayId, note: updated });
+ return ok({ note: updated });
+ }
+ );
+
+ server.registerTool(
+ 'delete_day_note',
+ {
+ description: 'Delete a note from a specific day.',
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ dayId: z.number().int().positive(),
+ noteId: z.number().int().positive(),
+ },
+ },
+ async ({ tripId, dayId, noteId }) => {
+ if (isDemoUser(userId)) return demoDenied();
+ if (!canAccessTrip(tripId, userId)) return noAccess();
+ const note = db.prepare('SELECT id FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(noteId, dayId, tripId);
+ if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
+ db.prepare('DELETE FROM day_notes WHERE id = ?').run(noteId);
+ broadcast(tripId, 'dayNote:deleted', { noteId, dayId });
+ return ok({ success: true });
+ }
+ );
+}
diff --git a/server/src/middleware/mfaPolicy.ts b/server/src/middleware/mfaPolicy.ts
new file mode 100644
index 0000000..2912faa
--- /dev/null
+++ b/server/src/middleware/mfaPolicy.ts
@@ -0,0 +1,98 @@
+import { Request, Response, NextFunction } from 'express';
+import jwt from 'jsonwebtoken';
+import { db } from '../db/database';
+import { JWT_SECRET } from '../config';
+
+/** Paths that never require MFA (public or pre-auth). */
+function isPublicApiPath(method: string, pathNoQuery: string): boolean {
+ if (method === 'GET' && pathNoQuery === '/api/health') return true;
+ if (method === 'GET' && pathNoQuery === '/api/auth/app-config') return true;
+ if (method === 'POST' && pathNoQuery === '/api/auth/login') return true;
+ if (method === 'POST' && pathNoQuery === '/api/auth/register') return true;
+ if (method === 'POST' && pathNoQuery === '/api/auth/demo-login') return true;
+ if (method === 'GET' && pathNoQuery.startsWith('/api/auth/invite/')) return true;
+ if (method === 'POST' && pathNoQuery === '/api/auth/mfa/verify-login') return true;
+ if (pathNoQuery.startsWith('/api/auth/oidc/')) return true;
+ return false;
+}
+
+/** Authenticated paths allowed while MFA is not yet enabled (setup + lockout recovery). */
+function isMfaSetupExemptPath(method: string, pathNoQuery: string): boolean {
+ if (method === 'GET' && pathNoQuery === '/api/auth/me') return true;
+ if (method === 'POST' && pathNoQuery === '/api/auth/mfa/setup') return true;
+ if (method === 'POST' && pathNoQuery === '/api/auth/mfa/enable') return true;
+ if ((method === 'GET' || method === 'PUT') && pathNoQuery === '/api/auth/app-settings') return true;
+ return false;
+}
+
+/**
+ * When app_settings.require_mfa is true, block API access for users without MFA enabled,
+ * except for public routes and MFA setup endpoints.
+ */
+export function enforceGlobalMfaPolicy(req: Request, res: Response, next: NextFunction): void {
+ const pathNoQuery = (req.originalUrl || req.url || '').split('?')[0];
+
+ if (!pathNoQuery.startsWith('/api')) {
+ next();
+ return;
+ }
+
+ if (isPublicApiPath(req.method, pathNoQuery)) {
+ next();
+ return;
+ }
+
+ const authHeader = req.headers.authorization;
+ const token = authHeader && authHeader.split(' ')[1];
+ if (!token) {
+ next();
+ return;
+ }
+
+ let userId: number;
+ try {
+ const decoded = jwt.verify(token, JWT_SECRET) as { id: number };
+ userId = decoded.id;
+ } catch {
+ next();
+ return;
+ }
+
+ const requireRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
+ if (requireRow?.value !== 'true') {
+ next();
+ return;
+ }
+
+ if (process.env.DEMO_MODE === 'true') {
+ const demo = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined;
+ if (demo?.email === 'demo@trek.app' || demo?.email === 'demo@nomad.app') {
+ next();
+ return;
+ }
+ }
+
+ const row = db.prepare('SELECT mfa_enabled, role FROM users WHERE id = ?').get(userId) as
+ | { mfa_enabled: number | boolean; role: string }
+ | undefined;
+ if (!row) {
+ next();
+ return;
+ }
+
+ const mfaOk = row.mfa_enabled === 1 || row.mfa_enabled === true;
+ if (mfaOk) {
+ next();
+ return;
+ }
+
+ if (isMfaSetupExemptPath(req.method, pathNoQuery)) {
+ next();
+ return;
+ }
+
+ res.status(403).json({
+ error: 'Two-factor authentication is required. Complete setup in Settings.',
+ code: 'MFA_REQUIRED',
+ });
+}
diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts
index c7aaf2a..ecc3f58 100644
--- a/server/src/routes/admin.ts
+++ b/server/src/routes/admin.ts
@@ -7,11 +7,18 @@ import fs from 'fs';
import { db } from '../db/database';
import { authenticate, adminOnly } from '../middleware/auth';
import { AuthRequest, User, Addon } from '../types';
+import { writeAudit, getClientIp } from '../services/auditLog';
+import { revokeUserSessions } from '../mcp';
const router = express.Router();
router.use(authenticate, adminOnly);
+function utcSuffix(ts: string | null | undefined): string | null {
+ if (!ts) return null;
+ return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
+}
+
router.get('/users', (req: Request, res: Response) => {
const users = db.prepare(
'SELECT id, username, email, role, created_at, updated_at, last_login FROM users ORDER BY created_at DESC'
@@ -21,7 +28,13 @@ router.get('/users', (req: Request, res: Response) => {
const { getOnlineUserIds } = require('../websocket');
onlineUserIds = getOnlineUserIds();
} catch { /* */ }
- const usersWithStatus = users.map(u => ({ ...u, online: onlineUserIds.has(u.id) }));
+ const usersWithStatus = users.map(u => ({
+ ...u,
+ created_at: utcSuffix(u.created_at),
+ updated_at: utcSuffix(u.updated_at as string),
+ last_login: utcSuffix(u.last_login),
+ online: onlineUserIds.has(u.id),
+ }));
res.json({ users: usersWithStatus });
});
@@ -52,6 +65,14 @@ router.post('/users', (req: Request, res: Response) => {
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
).get(result.lastInsertRowid);
+ const authReq = req as AuthRequest;
+ writeAudit({
+ userId: authReq.user.id,
+ action: 'admin.user_create',
+ resource: String(result.lastInsertRowid),
+ ip: getClientIp(req),
+ details: { username: username.trim(), email: email.trim(), role: role || 'user' },
+ });
res.status(201).json({ user });
});
@@ -90,6 +111,19 @@ router.put('/users/:id', (req: Request, res: Response) => {
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
).get(req.params.id);
+ const authReq = req as AuthRequest;
+ const changed: string[] = [];
+ if (username) changed.push('username');
+ if (email) changed.push('email');
+ if (role) changed.push('role');
+ if (password) changed.push('password');
+ writeAudit({
+ userId: authReq.user.id,
+ action: 'admin.user_update',
+ resource: String(req.params.id),
+ ip: getClientIp(req),
+ details: { fields: changed },
+ });
res.json({ user: updated });
});
@@ -103,6 +137,12 @@ router.delete('/users/:id', (req: Request, res: Response) => {
if (!user) return res.status(404).json({ error: 'User not found' });
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
+ writeAudit({
+ userId: authReq.user.id,
+ action: 'admin.user_delete',
+ resource: String(req.params.id),
+ ip: getClientIp(req),
+ });
res.json({ success: true });
});
@@ -115,6 +155,48 @@ router.get('/stats', (_req: Request, res: Response) => {
res.json({ totalUsers, totalTrips, totalPlaces, totalFiles });
});
+router.get('/audit-log', (req: Request, res: Response) => {
+ const limitRaw = parseInt(String(req.query.limit || '100'), 10);
+ const offsetRaw = parseInt(String(req.query.offset || '0'), 10);
+ const limit = Math.min(Math.max(Number.isFinite(limitRaw) ? limitRaw : 100, 1), 500);
+ const offset = Math.max(Number.isFinite(offsetRaw) ? offsetRaw : 0, 0);
+ type Row = {
+ id: number;
+ created_at: string;
+ user_id: number | null;
+ username: string | null;
+ user_email: string | null;
+ action: string;
+ resource: string | null;
+ details: string | null;
+ ip: string | null;
+ };
+ const rows = db.prepare(`
+ SELECT a.id, a.created_at, a.user_id, u.username, u.email as user_email, a.action, a.resource, a.details, a.ip
+ FROM audit_log a
+ LEFT JOIN users u ON u.id = a.user_id
+ ORDER BY a.id DESC
+ LIMIT ? OFFSET ?
+ `).all(limit, offset) as Row[];
+ const total = (db.prepare('SELECT COUNT(*) as c FROM audit_log').get() as { c: number }).c;
+ res.json({
+ entries: rows.map((r) => {
+ let details: Record | null = null;
+ if (r.details) {
+ try {
+ details = JSON.parse(r.details) as Record;
+ } catch {
+ details = { _parse_error: true };
+ }
+ }
+ return { ...r, details };
+ }),
+ total,
+ limit,
+ offset,
+ });
+});
+
router.get('/oidc', (_req: Request, res: Response) => {
const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || '';
const secret = get('oidc_client_secret');
@@ -135,16 +217,25 @@ router.put('/oidc', (req: Request, res: Response) => {
if (client_secret !== undefined) set('oidc_client_secret', client_secret);
set('oidc_display_name', display_name);
set('oidc_only', oidc_only ? 'true' : 'false');
+ const authReq = req as AuthRequest;
+ writeAudit({
+ userId: authReq.user.id,
+ action: 'admin.oidc_update',
+ ip: getClientIp(req),
+ details: { oidc_only: !!oidc_only, issuer_set: !!issuer },
+ });
res.json({ success: true });
});
-router.post('/save-demo-baseline', (_req: Request, res: Response) => {
+router.post('/save-demo-baseline', (req: Request, res: Response) => {
if (process.env.DEMO_MODE !== 'true') {
return res.status(404).json({ error: 'Not found' });
}
try {
const { saveBaseline } = require('../demo/demo-reset');
saveBaseline();
+ const authReq = req as AuthRequest;
+ writeAudit({ userId: authReq.user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) });
res.json({ success: true, message: 'Demo baseline saved. Hourly resets will restore to this state.' });
} catch (err: unknown) {
console.error(err);
@@ -201,7 +292,7 @@ router.get('/version-check', async (_req: Request, res: Response) => {
}
});
-router.post('/update', async (_req: Request, res: Response) => {
+router.post('/update', async (req: Request, res: Response) => {
const rootDir = path.resolve(__dirname, '../../..');
const serverDir = path.resolve(__dirname, '../..');
const clientDir = path.join(rootDir, 'client');
@@ -224,6 +315,13 @@ router.post('/update', async (_req: Request, res: Response) => {
const { version: newVersion } = require('../../package.json');
steps.push({ step: 'version', version: newVersion });
+ const authReq = req as AuthRequest;
+ writeAudit({
+ userId: authReq.user.id,
+ action: 'admin.system_update',
+ resource: newVersion,
+ ip: getClientIp(req),
+ });
res.json({ success: true, steps, restarting: true });
setTimeout(() => {
@@ -260,24 +358,39 @@ router.post('/invites', (req: Request, res: Response) => {
? new Date(Date.now() + parseInt(expires_in_days) * 86400000).toISOString()
: null;
- db.prepare(
+ const ins = db.prepare(
'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by) VALUES (?, ?, ?, ?)'
).run(token, uses, expiresAt, authReq.user.id);
+ const inviteId = Number(ins.lastInsertRowid);
const invite = db.prepare(`
SELECT i.*, u.username as created_by_name
FROM invite_tokens i
JOIN users u ON i.created_by = u.id
- WHERE i.id = last_insert_rowid()
- `).get();
+ WHERE i.id = ?
+ `).get(inviteId);
+ writeAudit({
+ userId: authReq.user.id,
+ action: 'admin.invite_create',
+ resource: String(inviteId),
+ ip: getClientIp(req),
+ details: { max_uses: uses, expires_in_days: expires_in_days ?? null },
+ });
res.status(201).json({ invite });
});
-router.delete('/invites/:id', (_req: Request, res: Response) => {
- const invite = db.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(_req.params.id);
+router.delete('/invites/:id', (req: Request, res: Response) => {
+ const invite = db.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(req.params.id);
if (!invite) return res.status(404).json({ error: 'Invite not found' });
- db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(_req.params.id);
+ db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(req.params.id);
+ const authReq = req as AuthRequest;
+ writeAudit({
+ userId: authReq.user.id,
+ action: 'admin.invite_delete',
+ resource: String(req.params.id),
+ ip: getClientIp(req),
+ });
res.json({ success: true });
});
@@ -291,6 +404,13 @@ router.get('/bag-tracking', (_req: Request, res: Response) => {
router.put('/bag-tracking', (req: Request, res: Response) => {
const { enabled } = req.body;
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(enabled ? 'true' : 'false');
+ const authReq = req as AuthRequest;
+ writeAudit({
+ userId: authReq.user.id,
+ action: 'admin.bag_tracking',
+ ip: getClientIp(req),
+ details: { enabled: !!enabled },
+ });
res.json({ enabled: !!enabled });
});
@@ -337,10 +457,19 @@ router.put('/packing-templates/:id', (req: Request, res: Response) => {
res.json({ template: db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id) });
});
-router.delete('/packing-templates/:id', (_req: Request, res: Response) => {
- const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(_req.params.id);
+router.delete('/packing-templates/:id', (req: Request, res: Response) => {
+ const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id);
if (!template) return res.status(404).json({ error: 'Template not found' });
- db.prepare('DELETE FROM packing_templates WHERE id = ?').run(_req.params.id);
+ db.prepare('DELETE FROM packing_templates WHERE id = ?').run(req.params.id);
+ const authReq = req as AuthRequest;
+ const t = template as { name?: string };
+ writeAudit({
+ userId: authReq.user.id,
+ action: 'admin.packing_template_delete',
+ resource: String(req.params.id),
+ ip: getClientIp(req),
+ details: { name: t.name },
+ });
res.json({ success: true });
});
@@ -408,7 +537,33 @@ router.put('/addons/:id', (req: Request, res: Response) => {
if (enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, req.params.id);
if (config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(config), req.params.id);
const updated = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id) as Addon;
+ const authReq = req as AuthRequest;
+ writeAudit({
+ userId: authReq.user.id,
+ action: 'admin.addon_update',
+ resource: String(req.params.id),
+ ip: getClientIp(req),
+ details: { enabled: enabled !== undefined ? !!enabled : undefined, config_changed: config !== undefined },
+ });
res.json({ addon: { ...updated, enabled: !!updated.enabled, config: JSON.parse(updated.config || '{}') } });
});
+router.get('/mcp-tokens', (req: Request, res: Response) => {
+ const tokens = db.prepare(`
+ SELECT t.id, t.name, t.token_prefix, t.created_at, t.last_used_at, t.user_id, u.username
+ FROM mcp_tokens t
+ JOIN users u ON u.id = t.user_id
+ ORDER BY t.created_at DESC
+ `).all();
+ res.json({ tokens });
+});
+
+router.delete('/mcp-tokens/:id', (req: Request, res: Response) => {
+ const token = db.prepare('SELECT id, user_id FROM mcp_tokens WHERE id = ?').get(req.params.id) as { id: number; user_id: number } | undefined;
+ if (!token) return res.status(404).json({ error: 'Token not found' });
+ db.prepare('DELETE FROM mcp_tokens WHERE id = ?').run(req.params.id);
+ revokeUserSessions(token.user_id);
+ res.json({ success: true });
+});
+
export default router;
diff --git a/server/src/routes/atlas.ts b/server/src/routes/atlas.ts
index e692e5f..a34c0fa 100644
--- a/server/src/routes/atlas.ts
+++ b/server/src/routes/atlas.ts
@@ -277,10 +277,10 @@ router.get('/bucket-list', (req: Request, res: Response) => {
router.post('/bucket-list', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
- const { name, lat, lng, country_code, notes } = req.body;
+ const { name, lat, lng, country_code, notes, target_date } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Name is required' });
- const result = db.prepare('INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes) VALUES (?, ?, ?, ?, ?, ?)').run(
- authReq.user.id, name.trim(), lat ?? null, lng ?? null, country_code ?? null, notes ?? null
+ const result = db.prepare('INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes, target_date) VALUES (?, ?, ?, ?, ?, ?, ?)').run(
+ authReq.user.id, name.trim(), lat ?? null, lng ?? null, country_code ?? null, notes ?? null, target_date ?? null
);
const item = db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ item });
@@ -288,10 +288,25 @@ router.post('/bucket-list', (req: Request, res: Response) => {
router.put('/bucket-list/:id', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
- const { name, notes } = req.body;
+ const { name, notes, lat, lng, country_code, target_date } = req.body;
const item = db.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(req.params.id, authReq.user.id);
if (!item) return res.status(404).json({ error: 'Item not found' });
- db.prepare('UPDATE bucket_list SET name = COALESCE(?, name), notes = COALESCE(?, notes) WHERE id = ?').run(name?.trim() || null, notes ?? null, req.params.id);
+ db.prepare(`UPDATE bucket_list SET
+ name = COALESCE(?, name),
+ notes = CASE WHEN ? THEN ? ELSE notes END,
+ lat = CASE WHEN ? THEN ? ELSE lat END,
+ lng = CASE WHEN ? THEN ? ELSE lng END,
+ country_code = CASE WHEN ? THEN ? ELSE country_code END,
+ target_date = CASE WHEN ? THEN ? ELSE target_date END
+ WHERE id = ?`).run(
+ name?.trim() || null,
+ notes !== undefined ? 1 : 0, notes !== undefined ? (notes || null) : null,
+ lat !== undefined ? 1 : 0, lat !== undefined ? (lat || null) : null,
+ lng !== undefined ? 1 : 0, lng !== undefined ? (lng || null) : null,
+ country_code !== undefined ? 1 : 0, country_code !== undefined ? (country_code || null) : null,
+ target_date !== undefined ? 1 : 0, target_date !== undefined ? (target_date || null) : null,
+ req.params.id
+ );
res.json({ item: db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(req.params.id) });
});
diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts
index b5518ef..9bb6e92 100644
--- a/server/src/routes/auth.ts
+++ b/server/src/routes/auth.ts
@@ -4,6 +4,7 @@ import jwt from 'jsonwebtoken';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
+import crypto from 'crypto';
import { v4 as uuid } from 'uuid';
import fetch from 'node-fetch';
import { authenticator } from 'otplib';
@@ -12,12 +13,45 @@ import { db } from '../db/database';
import { authenticate, demoUploadBlock } from '../middleware/auth';
import { JWT_SECRET } from '../config';
import { encryptMfaSecret, decryptMfaSecret } from '../services/mfaCrypto';
+import { randomBytes, createHash } from 'crypto';
+import { revokeUserSessions } from '../mcp';
import { AuthRequest, User } from '../types';
+import { writeAudit, getClientIp } from '../services/auditLog';
+import { decrypt_api_key, maybe_encrypt_api_key } from '../services/apiKeyCrypto';
authenticator.options = { window: 1 };
const MFA_SETUP_TTL_MS = 15 * 60 * 1000;
const mfaSetupPending = new Map();
+const MFA_BACKUP_CODE_COUNT = 10;
+
+function normalizeBackupCode(input: string): string {
+ return String(input || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
+}
+
+function hashBackupCode(input: string): string {
+ return crypto.createHash('sha256').update(normalizeBackupCode(input)).digest('hex');
+}
+
+function generateBackupCodes(count = MFA_BACKUP_CODE_COUNT): string[] {
+ const codes: string[] = [];
+ while (codes.length < count) {
+ const raw = crypto.randomBytes(4).toString('hex').toUpperCase();
+ const code = `${raw.slice(0, 4)}-${raw.slice(4)}`;
+ if (!codes.includes(code)) codes.push(code);
+ }
+ return codes;
+}
+
+function parseBackupCodeHashes(raw: string | null | undefined): string[] {
+ if (!raw) return [];
+ try {
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed.filter(v => typeof v === 'string') : [];
+ } catch {
+ return [];
+ }
+}
function getPendingMfaSecret(userId: number): string | null {
const row = mfaSetupPending.get(userId);
@@ -28,6 +62,11 @@ function getPendingMfaSecret(userId: number): string | null {
return row.secret;
}
+function utcSuffix(ts: string | null | undefined): string | null {
+ if (!ts) return null;
+ return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
+}
+
function stripUserForClient(user: User): Record {
const {
password_hash: _p,
@@ -35,10 +74,14 @@ function stripUserForClient(user: User): Record {
openweather_api_key: _o,
unsplash_api_key: _u,
mfa_secret: _mf,
+ mfa_backup_codes: _mbc,
...rest
} = user;
return {
...rest,
+ created_at: utcSuffix(rest.created_at),
+ updated_at: utcSuffix(rest.updated_at),
+ last_login: utcSuffix(rest.last_login),
mfa_enabled: !!(user.mfa_enabled === 1 || user.mfa_enabled === true),
};
}
@@ -108,6 +151,11 @@ function maskKey(key: string | null | undefined): string | null {
return '----' + key.slice(-4);
}
+function mask_stored_api_key(key: string | null | undefined): string | null {
+ const plain = decrypt_api_key(key);
+ return maskKey(plain);
+}
+
function avatarUrl(user: { avatar?: string | null }): string | null {
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
}
@@ -134,6 +182,7 @@ router.get('/app-config', (_req: Request, res: Response) => {
);
const oidcOnlySetting = process.env.OIDC_ONLY || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_only'").get() as { value: string } | undefined)?.value;
const oidcOnlyMode = oidcConfigured && oidcOnlySetting === 'true';
+ const requireMfaRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
res.json({
allow_registration: isDemo ? false : allowRegistration,
has_users: userCount > 0,
@@ -142,10 +191,12 @@ router.get('/app-config', (_req: Request, res: Response) => {
oidc_configured: oidcConfigured,
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
oidc_only_mode: oidcOnlyMode,
+ require_mfa: requireMfaRow?.value === 'true',
allowed_file_types: (db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get() as { value: string } | undefined)?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv',
demo_mode: isDemo,
demo_email: isDemo ? 'demo@trek.app' : undefined,
demo_password: isDemo ? 'demo12345' : undefined,
+ timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
});
});
@@ -344,9 +395,9 @@ router.put('/me/maps-key', authenticate, (req: Request, res: Response) => {
db.prepare(
'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
- ).run(maps_api_key || null, authReq.user.id);
+ ).run(maybe_encrypt_api_key(maps_api_key), authReq.user.id);
- res.json({ success: true, maps_api_key: maps_api_key || null });
+ res.json({ success: true, maps_api_key: mask_stored_api_key(maps_api_key) });
});
router.put('/me/api-keys', authenticate, (req: Request, res: Response) => {
@@ -357,8 +408,8 @@ router.put('/me/api-keys', authenticate, (req: Request, res: Response) => {
db.prepare(
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
).run(
- maps_api_key !== undefined ? (maps_api_key || null) : current.maps_api_key,
- openweather_api_key !== undefined ? (openweather_api_key || null) : current.openweather_api_key,
+ maps_api_key !== undefined ? maybe_encrypt_api_key(maps_api_key) : current.maps_api_key,
+ openweather_api_key !== undefined ? maybe_encrypt_api_key(openweather_api_key) : current.openweather_api_key,
authReq.user.id
);
@@ -367,7 +418,7 @@ router.put('/me/api-keys', authenticate, (req: Request, res: Response) => {
).get(authReq.user.id) as Pick | undefined;
const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined;
- res.json({ success: true, user: { ...u, maps_api_key: maskKey(u?.maps_api_key), openweather_api_key: maskKey(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } });
+ res.json({ success: true, user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } });
});
router.put('/me/settings', authenticate, (req: Request, res: Response) => {
@@ -399,8 +450,8 @@ router.put('/me/settings', authenticate, (req: Request, res: Response) => {
const updates: string[] = [];
const params: (string | number | null)[] = [];
- if (maps_api_key !== undefined) { updates.push('maps_api_key = ?'); params.push(maps_api_key || null); }
- if (openweather_api_key !== undefined) { updates.push('openweather_api_key = ?'); params.push(openweather_api_key || null); }
+ if (maps_api_key !== undefined) { updates.push('maps_api_key = ?'); params.push(maybe_encrypt_api_key(maps_api_key)); }
+ if (openweather_api_key !== undefined) { updates.push('openweather_api_key = ?'); params.push(maybe_encrypt_api_key(openweather_api_key)); }
if (username !== undefined) { updates.push('username = ?'); params.push(username.trim()); }
if (email !== undefined) { updates.push('email = ?'); params.push(email.trim()); }
@@ -415,7 +466,7 @@ router.put('/me/settings', authenticate, (req: Request, res: Response) => {
).get(authReq.user.id) as Pick | undefined;
const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined;
- res.json({ success: true, user: { ...u, maps_api_key: maskKey(u?.maps_api_key), openweather_api_key: maskKey(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } });
+ res.json({ success: true, user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } });
});
router.get('/me/settings', authenticate, (req: Request, res: Response) => {
@@ -425,7 +476,12 @@ router.get('/me/settings', authenticate, (req: Request, res: Response) => {
).get(authReq.user.id) as Pick | undefined;
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
- res.json({ settings: { maps_api_key: user.maps_api_key, openweather_api_key: user.openweather_api_key } });
+ res.json({
+ settings: {
+ maps_api_key: decrypt_api_key(user.maps_api_key),
+ openweather_api_key: decrypt_api_key(user.openweather_api_key),
+ }
+ });
});
router.post('/avatar', authenticate, demoUploadBlock, avatarUpload.single('avatar'), (req: Request, res: Response) => {
@@ -470,9 +526,21 @@ router.get('/validate-keys', authenticate, async (req: Request, res: Response) =
const user = db.prepare('SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?').get(authReq.user.id) as Pick | undefined;
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
- const result = { maps: false, weather: false };
+ const result: {
+ maps: boolean;
+ weather: boolean;
+ maps_details: null | {
+ ok: boolean;
+ status: number | null;
+ status_text: string | null;
+ error_message: string | null;
+ error_status: string | null;
+ error_raw: string | null;
+ };
+ } = { maps: false, weather: false, maps_details: null };
- if (user.maps_api_key) {
+ const maps_api_key = decrypt_api_key(user.maps_api_key);
+ if (maps_api_key) {
try {
const mapsRes = await fetch(
`https://places.googleapis.com/v1/places:searchText`,
@@ -480,22 +548,54 @@ router.get('/validate-keys', authenticate, async (req: Request, res: Response) =
method: 'POST',
headers: {
'Content-Type': 'application/json',
- 'X-Goog-Api-Key': user.maps_api_key,
+ 'X-Goog-Api-Key': maps_api_key,
'X-Goog-FieldMask': 'places.displayName',
},
body: JSON.stringify({ textQuery: 'test' }),
}
);
result.maps = mapsRes.status === 200;
+ let error_text: string | null = null;
+ let error_json: any = null;
+ if (!result.maps) {
+ try {
+ error_text = await mapsRes.text();
+ try {
+ error_json = JSON.parse(error_text);
+ } catch {
+ error_json = null;
+ }
+ } catch {
+ error_text = null;
+ error_json = null;
+ }
+ }
+ result.maps_details = {
+ ok: result.maps,
+ status: mapsRes.status,
+ status_text: mapsRes.statusText || null,
+ error_message: error_json?.error?.message || null,
+ error_status: error_json?.error?.status || null,
+ error_raw: error_text,
+ };
} catch (err: unknown) {
result.maps = false;
+ result.maps_details = {
+ ok: false,
+ status: null,
+ status_text: null,
+ error_message: err instanceof Error ? err.message : 'Request failed',
+ error_status: 'FETCH_ERROR',
+ error_raw: null,
+ };
}
}
- if (user.openweather_api_key) {
+ const openweather_api_key = decrypt_api_key(user.openweather_api_key);
+ if (openweather_api_key) {
try {
const weatherRes = await fetch(
- `https://api.openweathermap.org/data/2.5/weather?q=London&appid=${user.openweather_api_key}`
+ `https://api.openweathermap.org/data/2.5/weather?q=London&appid=${openweather_api_key}`
);
result.weather = weatherRes.status === 200;
} catch (err: unknown) {
@@ -506,18 +606,58 @@ router.get('/validate-keys', authenticate, async (req: Request, res: Response) =
res.json(result);
});
+const ADMIN_SETTINGS_KEYS = ['allow_registration', 'allowed_file_types', 'require_mfa', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify', 'notification_webhook_url', 'app_url'];
+
+router.get('/app-settings', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const user = db.prepare('SELECT role FROM users WHERE id = ?').get(authReq.user.id) as { role: string } | undefined;
+ if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
+
+ const result: Record = {};
+ for (const key of ADMIN_SETTINGS_KEYS) {
+ const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined;
+ if (row) result[key] = key === 'smtp_pass' ? '••••••••' : row.value;
+ }
+ res.json(result);
+});
+
router.put('/app-settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(authReq.user.id) as { role: string } | undefined;
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
- const { allow_registration, allowed_file_types } = req.body;
- if (allow_registration !== undefined) {
- db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', ?)").run(String(allow_registration));
+ const { allow_registration, allowed_file_types, require_mfa } = req.body as Record;
+
+ if (require_mfa === true || require_mfa === 'true') {
+ const adminMfa = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(authReq.user.id) as { mfa_enabled: number } | undefined;
+ if (!(adminMfa?.mfa_enabled === 1)) {
+ return res.status(400).json({
+ error: 'Enable two-factor authentication on your own account before requiring it for all users.',
+ });
+ }
}
- if (allowed_file_types !== undefined) {
- db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', ?)").run(String(allowed_file_types));
+
+ for (const key of ADMIN_SETTINGS_KEYS) {
+ if (req.body[key] !== undefined) {
+ let val = String(req.body[key]);
+ if (key === 'require_mfa') {
+ val = req.body[key] === true || val === 'true' ? 'true' : 'false';
+ }
+ // Don't save masked password
+ if (key === 'smtp_pass' && val === '••••••••') continue;
+ db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val);
+ }
}
+ writeAudit({
+ userId: authReq.user.id,
+ action: 'settings.app_update',
+ ip: getClientIp(req),
+ details: {
+ allow_registration: allow_registration !== undefined ? Boolean(allow_registration) : undefined,
+ allowed_file_types_changed: allowed_file_types !== undefined,
+ require_mfa: require_mfa !== undefined ? (require_mfa === true || require_mfa === 'true') : undefined,
+ },
+ });
res.json({ success: true });
});
@@ -610,10 +750,20 @@ router.post('/mfa/verify-login', authLimiter, (req: Request, res: Response) => {
return res.status(401).json({ error: 'Invalid session' });
}
const secret = decryptMfaSecret(user.mfa_secret);
- const tokenStr = String(code).replace(/\s/g, '');
- const ok = authenticator.verify({ token: tokenStr, secret });
- if (!ok) {
- return res.status(401).json({ error: 'Invalid verification code' });
+ const tokenStr = String(code).trim();
+ const okTotp = authenticator.verify({ token: tokenStr.replace(/\s/g, ''), secret });
+ if (!okTotp) {
+ const hashes = parseBackupCodeHashes(user.mfa_backup_codes);
+ const candidateHash = hashBackupCode(tokenStr);
+ const idx = hashes.findIndex(h => h === candidateHash);
+ if (idx === -1) {
+ return res.status(401).json({ error: 'Invalid verification code' });
+ }
+ hashes.splice(idx, 1);
+ db.prepare('UPDATE users SET mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
+ JSON.stringify(hashes),
+ user.id
+ );
}
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
const sessionToken = generateToken(user);
@@ -667,13 +817,17 @@ router.post('/mfa/enable', authenticate, (req: Request, res: Response) => {
if (!ok) {
return res.status(401).json({ error: 'Invalid verification code' });
}
+ const backupCodes = generateBackupCodes();
+ const backupHashes = backupCodes.map(hashBackupCode);
const enc = encryptMfaSecret(pending);
- db.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
+ db.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
enc,
+ JSON.stringify(backupHashes),
authReq.user.id
);
mfaSetupPending.delete(authReq.user.id);
- res.json({ success: true, mfa_enabled: true });
+ writeAudit({ userId: authReq.user.id, action: 'user.mfa_enable', ip: getClientIp(req) });
+ res.json({ success: true, mfa_enabled: true, backup_codes: backupCodes });
});
router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
@@ -681,6 +835,10 @@ router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (re
if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@nomad.app') {
return res.status(403).json({ error: 'MFA cannot be changed in demo mode.' });
}
+ const policy = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
+ if (policy?.value === 'true') {
+ return res.status(403).json({ error: 'Two-factor authentication cannot be disabled while it is required for all users.' });
+ }
const { password, code } = req.body as { password?: string; code?: string };
if (!password || !code) {
return res.status(400).json({ error: 'Password and authenticator code are required' });
@@ -698,11 +856,56 @@ router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (re
if (!ok) {
return res.status(401).json({ error: 'Invalid verification code' });
}
- db.prepare('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
+ db.prepare('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, mfa_backup_codes = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
authReq.user.id
);
mfaSetupPending.delete(authReq.user.id);
+ writeAudit({ userId: authReq.user.id, action: 'user.mfa_disable', ip: getClientIp(req) });
res.json({ success: true, mfa_enabled: false });
});
+// --- MCP Token Management ---
+
+router.get('/mcp-tokens', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const tokens = db.prepare(
+ 'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE user_id = ? ORDER BY created_at DESC'
+ ).all(authReq.user.id);
+ res.json({ tokens });
+});
+
+router.post('/mcp-tokens', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { name } = req.body;
+ if (!name?.trim()) return res.status(400).json({ error: 'Token name is required' });
+ if (name.trim().length > 100) return res.status(400).json({ error: 'Token name must be 100 characters or less' });
+
+ const tokenCount = (db.prepare('SELECT COUNT(*) as count FROM mcp_tokens WHERE user_id = ?').get(authReq.user.id) as { count: number }).count;
+ if (tokenCount >= 10) return res.status(400).json({ error: 'Maximum of 10 tokens per user reached' });
+
+ const rawToken = 'trek_' + randomBytes(24).toString('hex');
+ const tokenHash = createHash('sha256').update(rawToken).digest('hex');
+ const tokenPrefix = rawToken.slice(0, 13); // "trek_" + 8 hex chars
+
+ const result = db.prepare(
+ 'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)'
+ ).run(authReq.user.id, name.trim(), tokenHash, tokenPrefix);
+
+ const token = db.prepare(
+ 'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE id = ?'
+ ).get(result.lastInsertRowid);
+
+ res.status(201).json({ token: { ...(token as object), raw_token: rawToken } });
+});
+
+router.delete('/mcp-tokens/:id', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { id } = req.params;
+ const token = db.prepare('SELECT id FROM mcp_tokens WHERE id = ? AND user_id = ?').get(id, authReq.user.id);
+ if (!token) return res.status(404).json({ error: 'Token not found' });
+ db.prepare('DELETE FROM mcp_tokens WHERE id = ?').run(id);
+ revokeUserSessions(authReq.user.id);
+ res.json({ success: true });
+});
+
export default router;
diff --git a/server/src/routes/backup.ts b/server/src/routes/backup.ts
index e18190d..de47375 100644
--- a/server/src/routes/backup.ts
+++ b/server/src/routes/backup.ts
@@ -7,6 +7,10 @@ import fs from 'fs';
import { authenticate, adminOnly } from '../middleware/auth';
import * as scheduler from '../scheduler';
import { db, closeDb, reinitialize } from '../db/database';
+import { AuthRequest } from '../types';
+import { writeAudit, getClientIp } from '../services/auditLog';
+
+type RestoreAuditInfo = { userId: number; ip: string | null; source: 'backup.restore' | 'backup.upload_restore'; label: string };
const router = express.Router();
@@ -103,6 +107,14 @@ router.post('/create', backupRateLimiter(3, BACKUP_RATE_WINDOW), async (_req: Re
});
const stat = fs.statSync(outputPath);
+ const authReq = _req as AuthRequest;
+ writeAudit({
+ userId: authReq.user.id,
+ action: 'backup.create',
+ resource: filename,
+ ip: getClientIp(_req),
+ details: { size: stat.size },
+ });
res.json({
success: true,
backup: {
@@ -134,7 +146,7 @@ router.get('/download/:filename', (req: Request, res: Response) => {
res.download(filePath, filename);
});
-async function restoreFromZip(zipPath: string, res: Response) {
+async function restoreFromZip(zipPath: string, res: Response, audit?: RestoreAuditInfo) {
const extractDir = path.join(dataDir, `restore-${Date.now()}`);
try {
await fs.createReadStream(zipPath)
@@ -174,6 +186,14 @@ async function restoreFromZip(zipPath: string, res: Response) {
fs.rmSync(extractDir, { recursive: true, force: true });
+ if (audit) {
+ writeAudit({
+ userId: audit.userId,
+ action: audit.source,
+ resource: audit.label,
+ ip: audit.ip,
+ });
+ }
res.json({ success: true });
} catch (err: unknown) {
console.error('Restore error:', err);
@@ -191,7 +211,13 @@ router.post('/restore/:filename', async (req: Request, res: Response) => {
if (!fs.existsSync(zipPath)) {
return res.status(404).json({ error: 'Backup not found' });
}
- await restoreFromZip(zipPath, res);
+ const authReq = req as AuthRequest;
+ await restoreFromZip(zipPath, res, {
+ userId: authReq.user.id,
+ ip: getClientIp(req),
+ source: 'backup.restore',
+ label: filename,
+ });
});
const uploadTmp = multer({
@@ -206,23 +232,43 @@ const uploadTmp = multer({
router.post('/upload-restore', uploadTmp.single('backup'), async (req: Request, res: Response) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const zipPath = req.file.path;
- await restoreFromZip(zipPath, res);
+ const authReq = req as AuthRequest;
+ const origName = req.file.originalname || 'upload.zip';
+ await restoreFromZip(zipPath, res, {
+ userId: authReq.user.id,
+ ip: getClientIp(req),
+ source: 'backup.upload_restore',
+ label: origName,
+ });
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
});
router.get('/auto-settings', (_req: Request, res: Response) => {
try {
- res.json({ settings: scheduler.loadSettings() });
+ const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
+ res.json({ settings: scheduler.loadSettings(), timezone: tz });
} catch (err: unknown) {
console.error('[backup] GET auto-settings:', err);
res.status(500).json({ error: 'Could not load backup settings' });
}
});
+function parseIntField(raw: unknown, fallback: number): number {
+ if (typeof raw === 'number' && Number.isFinite(raw)) return Math.floor(raw);
+ if (typeof raw === 'string' && raw.trim() !== '') {
+ const n = parseInt(raw, 10);
+ if (Number.isFinite(n)) return n;
+ }
+ return fallback;
+}
+
function parseAutoBackupBody(body: Record): {
enabled: boolean;
interval: string;
keep_days: number;
+ hour: number;
+ day_of_week: number;
+ day_of_month: number;
} {
const enabled = body.enabled === true || body.enabled === 'true' || body.enabled === 1;
const rawInterval = body.interval;
@@ -230,17 +276,11 @@ function parseAutoBackupBody(body: Record): {
typeof rawInterval === 'string' && scheduler.VALID_INTERVALS.includes(rawInterval)
? rawInterval
: 'daily';
- const rawKeep = body.keep_days;
- let keepNum: number;
- if (typeof rawKeep === 'number' && Number.isFinite(rawKeep)) {
- keepNum = Math.floor(rawKeep);
- } else if (typeof rawKeep === 'string' && rawKeep.trim() !== '') {
- keepNum = parseInt(rawKeep, 10);
- } else {
- keepNum = NaN;
- }
- const keep_days = Number.isFinite(keepNum) && keepNum >= 0 ? keepNum : 7;
- return { enabled, interval, keep_days };
+ const keep_days = Math.max(0, parseIntField(body.keep_days, 7));
+ const hour = Math.min(23, Math.max(0, parseIntField(body.hour, 2)));
+ const day_of_week = Math.min(6, Math.max(0, parseIntField(body.day_of_week, 0)));
+ const day_of_month = Math.min(28, Math.max(1, parseIntField(body.day_of_month, 1)));
+ return { enabled, interval, keep_days, hour, day_of_week, day_of_month };
}
router.put('/auto-settings', (req: Request, res: Response) => {
@@ -248,6 +288,13 @@ router.put('/auto-settings', (req: Request, res: Response) => {
const settings = parseAutoBackupBody((req.body || {}) as Record);
scheduler.saveSettings(settings);
scheduler.start();
+ const authReq = req as AuthRequest;
+ writeAudit({
+ userId: authReq.user.id,
+ action: 'backup.auto_settings',
+ ip: getClientIp(req),
+ details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days },
+ });
res.json({ settings });
} catch (err: unknown) {
console.error('[backup] PUT auto-settings:', err);
@@ -272,6 +319,13 @@ router.delete('/:filename', (req: Request, res: Response) => {
}
fs.unlinkSync(filePath);
+ const authReq = req as AuthRequest;
+ writeAudit({
+ userId: authReq.user.id,
+ action: 'backup.delete',
+ resource: filename,
+ ip: getClientIp(req),
+ });
res.json({ success: true });
});
diff --git a/server/src/routes/budget.ts b/server/src/routes/budget.ts
index c1b9258..410f62b 100644
--- a/server/src/routes/budget.ts
+++ b/server/src/routes/budget.ts
@@ -195,6 +195,77 @@ router.put('/:id/members/:userId/paid', authenticate, (req: Request, res: Respon
broadcast(Number(tripId), 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: paid ? 1 : 0 }, req.headers['x-socket-id'] as string);
});
+// Settlement calculation: who owes whom
+router.get('/settlement', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { tripId } = req.params;
+ if (!canAccessTrip(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+
+ const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[];
+ const allMembers = db.prepare(`
+ SELECT bm.budget_item_id, bm.user_id, bm.paid, u.username, u.avatar
+ FROM budget_item_members bm
+ JOIN users u ON bm.user_id = u.id
+ WHERE bm.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
+ `).all(tripId) as (BudgetItemMember & { budget_item_id: number })[];
+
+ // Calculate net balance per user: positive = is owed money, negative = owes money
+ const balances: Record = {};
+
+ for (const item of items) {
+ const members = allMembers.filter(m => m.budget_item_id === item.id);
+ if (members.length === 0) continue;
+
+ const payers = members.filter(m => m.paid);
+ if (payers.length === 0) continue; // no one marked as paid
+
+ const sharePerMember = item.total_price / members.length;
+ const paidPerPayer = item.total_price / payers.length;
+
+ for (const m of members) {
+ if (!balances[m.user_id]) {
+ balances[m.user_id] = { user_id: m.user_id, username: m.username, avatar_url: avatarUrl(m), balance: 0 };
+ }
+ // Everyone owes their share
+ balances[m.user_id].balance -= sharePerMember;
+ // Payers get credited what they paid
+ if (m.paid) balances[m.user_id].balance += paidPerPayer;
+ }
+ }
+
+ // Calculate optimized payment flows (greedy algorithm)
+ const people = Object.values(balances).filter(b => Math.abs(b.balance) > 0.01);
+ const debtors = people.filter(p => p.balance < -0.01).map(p => ({ ...p, amount: -p.balance }));
+ const creditors = people.filter(p => p.balance > 0.01).map(p => ({ ...p, amount: p.balance }));
+
+ // Sort by amount descending for efficient matching
+ debtors.sort((a, b) => b.amount - a.amount);
+ creditors.sort((a, b) => b.amount - a.amount);
+
+ const flows: { from: { user_id: number; username: string; avatar_url: string | null }; to: { user_id: number; username: string; avatar_url: string | null }; amount: number }[] = [];
+
+ let di = 0, ci = 0;
+ while (di < debtors.length && ci < creditors.length) {
+ const transfer = Math.min(debtors[di].amount, creditors[ci].amount);
+ if (transfer > 0.01) {
+ flows.push({
+ from: { user_id: debtors[di].user_id, username: debtors[di].username, avatar_url: debtors[di].avatar_url },
+ to: { user_id: creditors[ci].user_id, username: creditors[ci].username, avatar_url: creditors[ci].avatar_url },
+ amount: Math.round(transfer * 100) / 100,
+ });
+ }
+ debtors[di].amount -= transfer;
+ creditors[ci].amount -= transfer;
+ if (debtors[di].amount < 0.01) di++;
+ if (creditors[ci].amount < 0.01) ci++;
+ }
+
+ res.json({
+ balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(b.balance * 100) / 100 })),
+ flows,
+ });
+});
+
router.delete('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
diff --git a/server/src/routes/collab.ts b/server/src/routes/collab.ts
index 0c908e5..d6d3860 100644
--- a/server/src/routes/collab.ts
+++ b/server/src/routes/collab.ts
@@ -420,6 +420,13 @@ router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (r
const formatted = formatMessage(message);
res.status(201).json({ message: formatted });
broadcast(tripId, 'collab:message:created', { message: formatted }, req.headers['x-socket-id'] as string);
+
+ // Notify trip members about new chat message
+ import('../services/notifications').then(({ notifyTripMembers }) => {
+ const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
+ const preview = text.trim().length > 80 ? text.trim().substring(0, 80) + '...' : text.trim();
+ notifyTripMembers(Number(tripId), authReq.user.id, 'collab_message', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, preview }).catch(() => {});
+ });
});
router.post('/messages/:id/react', authenticate, (req: Request, res: Response) => {
diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts
index 4efc5ce..e5dca07 100644
--- a/server/src/routes/immich.ts
+++ b/server/src/routes/immich.ts
@@ -77,20 +77,32 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' });
try {
- const resp = await fetch(`${user.immich_url}/api/search/metadata`, {
- method: 'POST',
- headers: { 'x-api-key': user.immich_api_key, 'Content-Type': 'application/json' },
- body: JSON.stringify({
- takenAfter: from ? `${from}T00:00:00.000Z` : undefined,
- takenBefore: to ? `${to}T23:59:59.999Z` : undefined,
- type: 'IMAGE',
- size: 200,
- }),
- signal: AbortSignal.timeout(15000),
- });
- if (!resp.ok) return res.status(resp.status).json({ error: 'Search failed' });
- const data = await resp.json() as { assets?: { items?: any[] } };
- const assets = (data.assets?.items || []).map((a: any) => ({
+ // Paginate through all results (Immich limits per-page to 1000)
+ const allAssets: any[] = [];
+ let page = 1;
+ const pageSize = 1000;
+ while (true) {
+ const resp = await fetch(`${user.immich_url}/api/search/metadata`, {
+ method: 'POST',
+ headers: { 'x-api-key': user.immich_api_key, 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ takenAfter: from ? `${from}T00:00:00.000Z` : undefined,
+ takenBefore: to ? `${to}T23:59:59.999Z` : undefined,
+ type: 'IMAGE',
+ size: pageSize,
+ page,
+ }),
+ signal: AbortSignal.timeout(15000),
+ });
+ if (!resp.ok) return res.status(resp.status).json({ error: 'Search failed' });
+ const data = await resp.json() as { assets?: { items?: any[] } };
+ const items = data.assets?.items || [];
+ allAssets.push(...items);
+ if (items.length < pageSize) break; // Last page
+ page++;
+ if (page > 20) break; // Safety limit (20k photos max)
+ }
+ const assets = allAssets.map((a: any) => ({
id: a.id,
takenAt: a.fileCreatedAt || a.createdAt,
city: a.exifInfo?.city || null,
@@ -143,6 +155,14 @@ router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response)
res.json({ success: true, added });
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
+
+ // Notify trip members about shared photos
+ if (shared && added > 0) {
+ import('../services/notifications').then(({ notifyTripMembers }) => {
+ const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
+ notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, count: String(added) }).catch(() => {});
+ });
+ }
});
// Remove a photo from a trip (own photos only)
diff --git a/server/src/routes/maps.ts b/server/src/routes/maps.ts
index b71e80d..58a7330 100644
--- a/server/src/routes/maps.ts
+++ b/server/src/routes/maps.ts
@@ -3,6 +3,7 @@ import fetch from 'node-fetch';
import { db } from '../db/database';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
+import { decrypt_api_key } from '../services/apiKeyCrypto';
interface NominatimResult {
osm_type: string;
@@ -197,9 +198,10 @@ const router = express.Router();
function getMapsKey(userId: number): string | null {
const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(userId) as { maps_api_key: string | null } | undefined;
- if (user?.maps_api_key) return user.maps_api_key;
+ const user_key = decrypt_api_key(user?.maps_api_key);
+ if (user_key) return user_key;
const admin = db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get() as { maps_api_key: string } | undefined;
- return admin?.maps_api_key || null;
+ return decrypt_api_key(admin?.maps_api_key) || null;
}
const photoCache = new Map();
@@ -474,4 +476,68 @@ router.get('/reverse', authenticate, async (req: Request, res: Response) => {
}
});
+// Resolve a Google Maps URL to place data (coordinates, name, address)
+router.post('/resolve-url', authenticate, async (req: Request, res: Response) => {
+ const { url } = req.body;
+ if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' });
+
+ try {
+ let resolvedUrl = url;
+
+ // Follow redirects for short URLs (goo.gl, maps.app.goo.gl)
+ if (url.includes('goo.gl') || url.includes('maps.app')) {
+ const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) });
+ resolvedUrl = redirectRes.url;
+ }
+
+ // Extract coordinates from Google Maps URL patterns:
+ // /@48.8566,2.3522,15z or /place/.../@48.8566,2.3522
+ // ?q=48.8566,2.3522 or ?ll=48.8566,2.3522
+ let lat: number | null = null;
+ let lng: number | null = null;
+ let placeName: string | null = null;
+
+ // Pattern: /@lat,lng
+ const atMatch = resolvedUrl.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/);
+ if (atMatch) { lat = parseFloat(atMatch[1]); lng = parseFloat(atMatch[2]); }
+
+ // Pattern: !3dlat!4dlng (Google Maps data params)
+ if (!lat) {
+ const dataMatch = resolvedUrl.match(/!3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)/);
+ if (dataMatch) { lat = parseFloat(dataMatch[1]); lng = parseFloat(dataMatch[2]); }
+ }
+
+ // Pattern: ?q=lat,lng or &q=lat,lng
+ if (!lat) {
+ const qMatch = resolvedUrl.match(/[?&]q=(-?\d+\.?\d*),(-?\d+\.?\d*)/);
+ if (qMatch) { lat = parseFloat(qMatch[1]); lng = parseFloat(qMatch[2]); }
+ }
+
+ // Extract place name from URL path: /place/Place+Name/@...
+ const placeMatch = resolvedUrl.match(/\/place\/([^/@]+)/);
+ if (placeMatch) {
+ placeName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
+ }
+
+ if (!lat || !lng || isNaN(lat) || isNaN(lng)) {
+ return res.status(400).json({ error: 'Could not extract coordinates from URL' });
+ }
+
+ // Reverse geocode to get address
+ const nominatimRes = await fetch(
+ `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`,
+ { headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' }, signal: AbortSignal.timeout(8000) }
+ );
+ const nominatim = await nominatimRes.json() as { display_name?: string; name?: string; address?: Record };
+
+ const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null;
+ const address = nominatim.display_name || null;
+
+ res.json({ lat, lng, name, address });
+ } catch (err: unknown) {
+ console.error('[Maps] URL resolve error:', err instanceof Error ? err.message : err);
+ res.status(400).json({ error: 'Failed to resolve URL' });
+ }
+});
+
export default router;
diff --git a/server/src/routes/notifications.ts b/server/src/routes/notifications.ts
new file mode 100644
index 0000000..19b6d70
--- /dev/null
+++ b/server/src/routes/notifications.ts
@@ -0,0 +1,58 @@
+import express, { Request, Response } from 'express';
+import { db } from '../db/database';
+import { authenticate } from '../middleware/auth';
+import { AuthRequest } from '../types';
+import { testSmtp } from '../services/notifications';
+
+const router = express.Router();
+
+// Get user's notification preferences
+router.get('/preferences', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ let prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
+ if (!prefs) {
+ db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(authReq.user.id);
+ prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
+ }
+ res.json({ preferences: prefs });
+});
+
+// Update user's notification preferences
+router.put('/preferences', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { notify_trip_invite, notify_booking_change, notify_trip_reminder, notify_webhook } = req.body;
+
+ // Ensure row exists
+ const existing = db.prepare('SELECT id FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
+ if (!existing) {
+ db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(authReq.user.id);
+ }
+
+ db.prepare(`UPDATE notification_preferences SET
+ notify_trip_invite = COALESCE(?, notify_trip_invite),
+ notify_booking_change = COALESCE(?, notify_booking_change),
+ notify_trip_reminder = COALESCE(?, notify_trip_reminder),
+ notify_webhook = COALESCE(?, notify_webhook)
+ WHERE user_id = ?`).run(
+ notify_trip_invite !== undefined ? (notify_trip_invite ? 1 : 0) : null,
+ notify_booking_change !== undefined ? (notify_booking_change ? 1 : 0) : null,
+ notify_trip_reminder !== undefined ? (notify_trip_reminder ? 1 : 0) : null,
+ notify_webhook !== undefined ? (notify_webhook ? 1 : 0) : null,
+ authReq.user.id
+ );
+
+ const prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
+ res.json({ preferences: prefs });
+});
+
+// Admin: test SMTP configuration
+router.post('/test-smtp', authenticate, async (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ if (authReq.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
+
+ const { email } = req.body;
+ const result = await testSmtp(email || authReq.user.email);
+ res.json(result);
+});
+
+export default router;
diff --git a/server/src/routes/oidc.ts b/server/src/routes/oidc.ts
index 404f017..f21a517 100644
--- a/server/src/routes/oidc.ts
+++ b/server/src/routes/oidc.ts
@@ -24,6 +24,9 @@ interface OidcUserInfo {
email?: string;
name?: string;
preferred_username?: string;
+ groups?: string[];
+ roles?: string[];
+ [key: string]: unknown;
}
const router = express.Router();
@@ -41,7 +44,7 @@ setInterval(() => {
}
}, AUTH_CODE_CLEANUP);
-const pendingStates = new Map();
+const pendingStates = new Map();
setInterval(() => {
const now = Date.now();
@@ -85,6 +88,23 @@ function generateToken(user: { id: number; username: string; email: string; role
);
}
+// Check if user should be admin based on OIDC claims
+// Env: OIDC_ADMIN_CLAIM (default: "groups"), OIDC_ADMIN_VALUE (required, e.g. "app-trek-admins")
+function resolveOidcRole(userInfo: OidcUserInfo, isFirstUser: boolean): 'admin' | 'user' {
+ if (isFirstUser) return 'admin';
+ const adminValue = process.env.OIDC_ADMIN_VALUE;
+ if (!adminValue) return 'user'; // No claim mapping configured
+ const claimKey = process.env.OIDC_ADMIN_CLAIM || 'groups';
+ const claimData = userInfo[claimKey];
+ if (Array.isArray(claimData)) {
+ return claimData.some(v => String(v) === adminValue) ? 'admin' : 'user';
+ }
+ if (typeof claimData === 'string') {
+ return claimData === adminValue ? 'admin' : 'user';
+ }
+ return 'user';
+}
+
function frontendUrl(path: string): string {
const base = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:5173';
return base + path;
@@ -104,8 +124,9 @@ router.get('/login', async (req: Request, res: Response) => {
const proto = (req.headers['x-forwarded-proto'] as string) || req.protocol;
const host = (req.headers['x-forwarded-host'] as string) || req.headers.host;
const redirectUri = `${proto}://${host}/api/auth/oidc/callback`;
+ const inviteToken = req.query.invite as string | undefined;
- pendingStates.set(state, { createdAt: Date.now(), redirectUri });
+ pendingStates.set(state, { createdAt: Date.now(), redirectUri, inviteToken });
const params = new URLSearchParams({
response_type: 'code',
@@ -190,18 +211,35 @@ router.get('/callback', async (req: Request, res: Response) => {
if (!user.oidc_sub) {
db.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, config.issuer, user.id);
}
+ // Update role based on OIDC claims on every login (if claim mapping is configured)
+ if (process.env.OIDC_ADMIN_VALUE) {
+ const newRole = resolveOidcRole(userInfo, false);
+ if (user.role !== newRole) {
+ db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id);
+ user = { ...user, role: newRole } as User;
+ }
+ }
} else {
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
const isFirstUser = userCount === 0;
- if (!isFirstUser) {
+ let validInvite: any = null;
+ if (pending.inviteToken) {
+ validInvite = db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(pending.inviteToken);
+ if (validInvite) {
+ if (validInvite.max_uses > 0 && validInvite.used_count >= validInvite.max_uses) validInvite = null;
+ if (validInvite?.expires_at && new Date(validInvite.expires_at) < new Date()) validInvite = null;
+ }
+ }
+
+ if (!isFirstUser && !validInvite) {
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
if (setting?.value === 'false') {
return res.redirect(frontendUrl('/login?oidc_error=registration_disabled'));
}
}
- const role = isFirstUser ? 'admin' : 'user';
+ const role = resolveOidcRole(userInfo, isFirstUser);
const randomPass = crypto.randomBytes(32).toString('hex');
const bcrypt = require('bcryptjs');
const hash = bcrypt.hashSync(randomPass, 10);
@@ -214,6 +252,15 @@ router.get('/callback', async (req: Request, res: Response) => {
'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer) VALUES (?, ?, ?, ?, ?, ?)'
).run(username, email, hash, role, sub, config.issuer);
+ if (validInvite) {
+ const updated = db.prepare(
+ 'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses)'
+ ).run(validInvite.id);
+ if (updated.changes === 0) {
+ console.warn(`[OIDC] Invite token ${pending.inviteToken?.slice(0, 8)}... exceeded max_uses (race condition)`);
+ }
+ }
+
user = { id: Number(result.lastInsertRowid), username, email, role } as User;
}
diff --git a/server/src/routes/packing.ts b/server/src/routes/packing.ts
index bf4e668..962cbd3 100644
--- a/server/src/routes/packing.ts
+++ b/server/src/routes/packing.ts
@@ -24,6 +24,53 @@ router.get('/', authenticate, (req: Request, res: Response) => {
res.json({ items });
});
+// Bulk import packing items (must be before /:id)
+router.post('/import', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { tripId } = req.params;
+ const { items } = req.body; // [{ name, category?, quantity? }]
+
+ const trip = verifyTripOwnership(tripId, authReq.user.id);
+ if (!trip) return res.status(404).json({ error: 'Trip not found' });
+
+ if (!Array.isArray(items) || items.length === 0) return res.status(400).json({ error: 'items must be a non-empty array' });
+
+ const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null };
+ let sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
+
+ const stmt = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, weight_grams, bag_id, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
+ const created: any[] = [];
+ const insertAll = db.transaction(() => {
+ for (const item of items) {
+ if (!item.name?.trim()) continue;
+ const checked = item.checked ? 1 : 0;
+ const weight = item.weight_grams ? parseInt(item.weight_grams) || null : null;
+ // Resolve bag by name if provided
+ let bagId = null;
+ if (item.bag?.trim()) {
+ const bagName = item.bag.trim();
+ const existing = db.prepare('SELECT id FROM packing_bags WHERE trip_id = ? AND name = ?').get(tripId, bagName) as { id: number } | undefined;
+ if (existing) {
+ bagId = existing.id;
+ } else {
+ const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b'];
+ const bagCount = (db.prepare('SELECT COUNT(*) as c FROM packing_bags WHERE trip_id = ?').get(tripId) as { c: number }).c;
+ const newBag = db.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(tripId, bagName, BAG_COLORS[bagCount % BAG_COLORS.length]);
+ bagId = newBag.lastInsertRowid;
+ }
+ }
+ const result = stmt.run(tripId, item.name.trim(), checked, item.category?.trim() || 'Other', weight, bagId, sortOrder++);
+ created.push(db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid));
+ }
+ });
+ insertAll();
+
+ res.status(201).json({ items: created, count: created.length });
+ for (const item of created) {
+ broadcast(tripId, 'packing:created', { item }, req.headers['x-socket-id'] as string);
+ }
+});
+
router.post('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
@@ -231,6 +278,18 @@ router.put('/category-assignees/:categoryName', authenticate, (req: Request, res
res.json({ assignees: rows });
broadcast(tripId, 'packing:assignees', { category: cat, assignees: rows }, req.headers['x-socket-id'] as string);
+
+ // Notify newly assigned users
+ if (Array.isArray(user_ids) && user_ids.length > 0) {
+ import('../services/notifications').then(({ notify }) => {
+ const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
+ for (const uid of user_ids) {
+ if (uid !== authReq.user.id) {
+ notify({ userId: uid, event: 'packing_tagged', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, category: cat } }).catch(() => {});
+ }
+ }
+ });
+ }
});
router.put('/reorder', authenticate, (req: Request, res: Response) => {
diff --git a/server/src/routes/places.ts b/server/src/routes/places.ts
index 176aa3c..f304e6a 100644
--- a/server/src/routes/places.ts
+++ b/server/src/routes/places.ts
@@ -1,5 +1,6 @@
import express, { Request, Response } from 'express';
import fetch from 'node-fetch';
+import multer from 'multer';
import { db, getPlaceWithTags } from '../db/database';
import { authenticate } from '../middleware/auth';
import { requireTripAccess } from '../middleware/tripAccess';
@@ -8,6 +9,8 @@ import { loadTagsByPlaceIds } from '../services/queryHelpers';
import { validateStringLengths } from '../middleware/validate';
import { AuthRequest, Place } from '../types';
+const gpxUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
+
interface PlaceWithCategory extends Place {
category_name: string | null;
category_color: string | null;
@@ -112,6 +115,94 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name:
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
});
+// Import places from GPX file (must be before /:id)
+router.post('/import/gpx', authenticate, requireTripAccess, gpxUpload.single('file'), (req: Request, res: Response) => {
+ const { tripId } = req.params;
+ const file = (req as any).file;
+ if (!file) return res.status(400).json({ error: 'No file uploaded' });
+
+ const xml = file.buffer.toString('utf-8');
+
+ const parseCoords = (attrs: string): { lat: number; lng: number } | null => {
+ const latMatch = attrs.match(/lat=["']([^"']+)["']/i);
+ const lonMatch = attrs.match(/lon=["']([^"']+)["']/i);
+ if (!latMatch || !lonMatch) return null;
+ const lat = parseFloat(latMatch[1]);
+ const lng = parseFloat(lonMatch[1]);
+ return (!isNaN(lat) && !isNaN(lng)) ? { lat, lng } : null;
+ };
+
+ const stripCdata = (s: string) => s.replace(//g, '$1').trim();
+ const extractName = (body: string) => { const m = body.match(/]*>([\s\S]*?)<\/name>/i); return m ? stripCdata(m[1]) : null };
+ const extractDesc = (body: string) => { const m = body.match(/]*>([\s\S]*?)<\/desc>/i); return m ? stripCdata(m[1]) : null };
+
+ const waypoints: { name: string; lat: number; lng: number; description: string | null }[] = [];
+
+ // 1) Parse elements (named waypoints / POIs)
+ const wptRegex = /]+)>([\s\S]*?)<\/wpt>/gi;
+ let match;
+ while ((match = wptRegex.exec(xml)) !== null) {
+ const coords = parseCoords(match[1]);
+ if (!coords) continue;
+ const name = extractName(match[2]) || `Waypoint ${waypoints.length + 1}`;
+ waypoints.push({ ...coords, name, description: extractDesc(match[2]) });
+ }
+
+ // 2) If no , try (route points)
+ if (waypoints.length === 0) {
+ const rteptRegex = /]+)>([\s\S]*?)<\/rtept>/gi;
+ while ((match = rteptRegex.exec(xml)) !== null) {
+ const coords = parseCoords(match[1]);
+ if (!coords) continue;
+ const name = extractName(match[2]) || `Route Point ${waypoints.length + 1}`;
+ waypoints.push({ ...coords, name, description: extractDesc(match[2]) });
+ }
+ }
+
+ // 3) If still nothing, extract track name + start/end points from
+ if (waypoints.length === 0) {
+ const trackNameMatch = xml.match(/]*>[\s\S]*?]*>([\s\S]*?)<\/name>/i);
+ const trackName = trackNameMatch?.[1]?.trim() || 'GPX Track';
+ const trkptRegex = /]*?)(?:\/>|>([\s\S]*?)<\/trkpt>)/gi;
+ const trackPoints: { lat: number; lng: number }[] = [];
+ while ((match = trkptRegex.exec(xml)) !== null) {
+ const coords = parseCoords(match[1]);
+ if (coords) trackPoints.push(coords);
+ }
+ if (trackPoints.length > 0) {
+ const start = trackPoints[0];
+ waypoints.push({ ...start, name: `${trackName} — Start`, description: null });
+ if (trackPoints.length > 1) {
+ const end = trackPoints[trackPoints.length - 1];
+ waypoints.push({ ...end, name: `${trackName} — End`, description: null });
+ }
+ }
+ }
+
+ if (waypoints.length === 0) {
+ return res.status(400).json({ error: 'No waypoints found in GPX file' });
+ }
+
+ const insertStmt = db.prepare(`
+ INSERT INTO places (trip_id, name, description, lat, lng, transport_mode)
+ VALUES (?, ?, ?, ?, ?, 'walking')
+ `);
+ const created: any[] = [];
+ const insertAll = db.transaction(() => {
+ for (const wp of waypoints) {
+ const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng);
+ const place = getPlaceWithTags(Number(result.lastInsertRowid));
+ created.push(place);
+ }
+ });
+ insertAll();
+
+ res.status(201).json({ places: created, count: created.length });
+ for (const place of created) {
+ broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
+ }
+});
+
router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId, id } = req.params
diff --git a/server/src/routes/reservations.ts b/server/src/routes/reservations.ts
index 302c1a1..0c8b09c 100644
--- a/server/src/routes/reservations.ts
+++ b/server/src/routes/reservations.ts
@@ -101,6 +101,35 @@ router.post('/', authenticate, (req: Request, res: Response) => {
res.status(201).json({ reservation });
broadcast(tripId, 'reservation:created', { reservation }, req.headers['x-socket-id'] as string);
+
+ // Notify trip members about new booking
+ import('../services/notifications').then(({ notifyTripMembers }) => {
+ const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
+ notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, booking: title, type: type || 'booking' }).catch(() => {});
+ });
+});
+
+// Batch update day_plan_position for multiple reservations (must be before /:id)
+router.put('/positions', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { tripId } = req.params;
+ const { positions } = req.body;
+
+ const trip = verifyTripOwnership(tripId, authReq.user.id);
+ if (!trip) return res.status(404).json({ error: 'Trip not found' });
+
+ if (!Array.isArray(positions)) return res.status(400).json({ error: 'positions must be an array' });
+
+ const stmt = db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?');
+ const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => {
+ for (const item of items) {
+ stmt.run(item.day_plan_position, item.id, tripId);
+ }
+ });
+ updateMany(positions);
+
+ res.json({ success: true });
+ broadcast(tripId, 'reservation:positions', { positions }, req.headers['x-socket-id'] as string);
});
router.put('/:id', authenticate, (req: Request, res: Response) => {
diff --git a/server/src/routes/share.ts b/server/src/routes/share.ts
new file mode 100644
index 0000000..d36ca03
--- /dev/null
+++ b/server/src/routes/share.ts
@@ -0,0 +1,165 @@
+import express, { Request, Response } from 'express';
+import crypto from 'crypto';
+import { db, canAccessTrip } from '../db/database';
+import { authenticate } from '../middleware/auth';
+import { AuthRequest } from '../types';
+import { loadTagsByPlaceIds } from '../services/queryHelpers';
+
+const router = express.Router();
+
+// Create a share link for a trip (owner/member only)
+router.post('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { tripId } = req.params;
+ if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+
+ const { share_map = true, share_bookings = true, share_packing = false, share_budget = false, share_collab = false } = req.body || {};
+
+ // Check if token already exists
+ const existing = db.prepare('SELECT token FROM share_tokens WHERE trip_id = ?').get(tripId) as { token: string } | undefined;
+ if (existing) {
+ // Update permissions
+ db.prepare('UPDATE share_tokens SET share_map = ?, share_bookings = ?, share_packing = ?, share_budget = ?, share_collab = ? WHERE trip_id = ?')
+ .run(share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, tripId);
+ return res.json({ token: existing.token });
+ }
+
+ const token = crypto.randomBytes(24).toString('base64url');
+ db.prepare('INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
+ .run(tripId, token, authReq.user.id, share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0);
+ res.status(201).json({ token });
+});
+
+// Get share link status
+router.get('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { tripId } = req.params;
+ if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+
+ const row = db.prepare('SELECT * FROM share_tokens WHERE trip_id = ?').get(tripId) as any;
+ res.json(row ? { token: row.token, created_at: row.created_at, share_map: !!row.share_map, share_bookings: !!row.share_bookings, share_packing: !!row.share_packing, share_budget: !!row.share_budget, share_collab: !!row.share_collab } : { token: null });
+});
+
+// Delete share link
+router.delete('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { tripId } = req.params;
+ if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
+
+ db.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(tripId);
+ res.json({ success: true });
+});
+
+// Public read-only trip data (no auth required)
+router.get('/shared/:token', (req: Request, res: Response) => {
+ const { token } = req.params;
+ const shareRow = db.prepare('SELECT * FROM share_tokens WHERE token = ?').get(token) as any;
+ if (!shareRow) return res.status(404).json({ error: 'Invalid or expired link' });
+
+ const tripId = shareRow.trip_id;
+
+ // Trip
+ const trip = db.prepare('SELECT id, title, description, start_date, end_date, cover_image, currency FROM trips WHERE id = ?').get(tripId);
+ if (!trip) return res.status(404).json({ error: 'Trip not found' });
+
+ // Days with assignments
+ const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as any[];
+ const dayIds = days.map(d => d.id);
+
+ let assignments = {};
+ let dayNotes = {};
+ if (dayIds.length > 0) {
+ const ph = dayIds.map(() => '?').join(',');
+ const allAssignments = db.prepare(`
+ SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
+ p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
+ COALESCE(da.assignment_time, p.place_time) as place_time,
+ COALESCE(da.assignment_end_time, p.end_time) as end_time,
+ p.duration_minutes, p.notes as place_notes, p.image_url, p.transport_mode,
+ c.name as category_name, c.color as category_color, c.icon as category_icon
+ FROM day_assignments da
+ JOIN places p ON da.place_id = p.id
+ LEFT JOIN categories c ON p.category_id = c.id
+ WHERE da.day_id IN (${ph})
+ ORDER BY da.order_index ASC
+ `).all(...dayIds);
+
+ const placeIds = [...new Set(allAssignments.map((a: any) => a.place_id))];
+ const tagsByPlace = loadTagsByPlaceIds(placeIds, { compact: true });
+
+ const byDay: Record = {};
+ for (const a of allAssignments as any[]) {
+ if (!byDay[a.day_id]) byDay[a.day_id] = [];
+ byDay[a.day_id].push({
+ id: a.id, day_id: a.day_id, order_index: a.order_index, notes: a.notes,
+ place: {
+ id: a.place_id, name: a.place_name, description: a.place_description,
+ lat: a.lat, lng: a.lng, address: a.address, category_id: a.category_id,
+ price: a.price, place_time: a.place_time, end_time: a.end_time,
+ image_url: a.image_url, transport_mode: a.transport_mode,
+ category: a.category_id ? { id: a.category_id, name: a.category_name, color: a.category_color, icon: a.category_icon } : null,
+ tags: tagsByPlace[a.place_id] || [],
+ }
+ });
+ }
+ assignments = byDay;
+
+ const allNotes = db.prepare(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC`).all(...dayIds);
+ const notesByDay: Record = {};
+ for (const n of allNotes as any[]) {
+ if (!notesByDay[n.day_id]) notesByDay[n.day_id] = [];
+ notesByDay[n.day_id].push(n);
+ }
+ dayNotes = notesByDay;
+ }
+
+ // Places
+ const places = db.prepare(`
+ SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
+ FROM places p LEFT JOIN categories c ON p.category_id = c.id
+ WHERE p.trip_id = ? ORDER BY p.created_at DESC
+ `).all(tripId);
+
+ // Reservations
+ const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId);
+
+ // Accommodations
+ const accommodations = db.prepare(`
+ SELECT a.*, p.name as place_name, p.address as place_address, p.lat as place_lat, p.lng as place_lng
+ FROM day_accommodations a JOIN places p ON a.place_id = p.id
+ WHERE a.trip_id = ?
+ `).all(tripId);
+
+ // Packing
+ const packing = db.prepare('SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC').all(tripId);
+
+ // Budget
+ const budget = db.prepare('SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC').all(tripId);
+
+ // Categories
+ const categories = db.prepare('SELECT * FROM categories').all();
+
+ const permissions = {
+ share_map: !!shareRow.share_map,
+ share_bookings: !!shareRow.share_bookings,
+ share_packing: !!shareRow.share_packing,
+ share_budget: !!shareRow.share_budget,
+ share_collab: !!shareRow.share_collab,
+ };
+
+ // Only include data the owner chose to share
+ const collabMessages = permissions.share_collab
+ ? db.prepare('SELECT m.*, u.username, u.avatar FROM collab_messages m JOIN users u ON m.user_id = u.id WHERE m.trip_id = ? ORDER BY m.created_at ASC').all(tripId)
+ : [];
+
+ res.json({
+ trip, days, assignments, dayNotes, places, categories, permissions,
+ reservations: permissions.share_bookings ? reservations : [],
+ accommodations: permissions.share_bookings ? accommodations : [],
+ packing: permissions.share_packing ? packing : [],
+ budget: permissions.share_budget ? budget : [],
+ collab: collabMessages,
+ });
+});
+
+export default router;
diff --git a/server/src/routes/trips.ts b/server/src/routes/trips.ts
index ad5231d..f2a7c9d 100644
--- a/server/src/routes/trips.ts
+++ b/server/src/routes/trips.ts
@@ -284,6 +284,12 @@ router.post('/:id/members', authenticate, (req: Request, res: Response) => {
db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(req.params.id, target.id, authReq.user.id);
+ // Notify invited user
+ const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(req.params.id) as { title: string } | undefined;
+ import('../services/notifications').then(({ notify }) => {
+ notify({ userId: target.id, event: 'trip_invite', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username } }).catch(() => {});
+ });
+
res.status(201).json({ member: { ...target, role: 'member', avatar_url: target.avatar ? `/uploads/avatars/${target.avatar}` : null } });
});
@@ -301,4 +307,83 @@ router.delete('/:id/members/:userId', authenticate, (req: Request, res: Response
res.json({ success: true });
});
+// ICS calendar export
+router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ if (!canAccessTrip(req.params.id, authReq.user.id))
+ return res.status(404).json({ error: 'Trip not found' });
+
+ const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id) as any;
+ if (!trip) return res.status(404).json({ error: 'Trip not found' });
+
+ const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(req.params.id) as any[];
+ const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(req.params.id) as any[];
+
+ const esc = (s: string) => s.replace(/[\\;,\n]/g, m => m === '\n' ? '\\n' : '\\' + m);
+ const fmtDate = (d: string) => d.replace(/-/g, '');
+ const now = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
+ const uid = (id: number, type: string) => `trek-${type}-${id}@trek`;
+
+ // Format datetime: handles full ISO "2026-03-30T09:00" and time-only "10:00"
+ const fmtDateTime = (d: string, refDate?: string) => {
+ if (d.includes('T')) return d.replace(/[-:]/g, '').split('.')[0];
+ // Time-only: combine with reference date
+ if (refDate && d.match(/^\d{2}:\d{2}/)) {
+ const datePart = refDate.split('T')[0];
+ return `${datePart}T${d.replace(/:/g, '')}00`.replace(/-/g, '');
+ }
+ return d.replace(/[-:]/g, '');
+ };
+
+ let ics = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\n';
+ ics += `X-WR-CALNAME:${esc(trip.title || 'TREK Trip')}\r\n`;
+
+ // Trip as all-day event
+ if (trip.start_date && trip.end_date) {
+ const endNext = new Date(trip.end_date + 'T00:00:00');
+ endNext.setDate(endNext.getDate() + 1);
+ const endStr = endNext.toISOString().split('T')[0].replace(/-/g, '');
+ ics += `BEGIN:VEVENT\r\nUID:${uid(trip.id, 'trip')}\r\nDTSTAMP:${now}\r\nDTSTART;VALUE=DATE:${fmtDate(trip.start_date)}\r\nDTEND;VALUE=DATE:${endStr}\r\nSUMMARY:${esc(trip.title || 'Trip')}\r\n`;
+ if (trip.description) ics += `DESCRIPTION:${esc(trip.description)}\r\n`;
+ ics += `END:VEVENT\r\n`;
+ }
+
+ // Reservations as events
+ for (const r of reservations) {
+ if (!r.reservation_time) continue;
+ const hasTime = r.reservation_time.includes('T');
+ const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {};
+
+ ics += `BEGIN:VEVENT\r\nUID:${uid(r.id, 'res')}\r\nDTSTAMP:${now}\r\n`;
+ if (hasTime) {
+ ics += `DTSTART:${fmtDateTime(r.reservation_time)}\r\n`;
+ if (r.reservation_end_time) {
+ const endDt = fmtDateTime(r.reservation_end_time, r.reservation_time);
+ if (endDt.length >= 15) ics += `DTEND:${endDt}\r\n`;
+ }
+ } else {
+ ics += `DTSTART;VALUE=DATE:${fmtDate(r.reservation_time)}\r\n`;
+ }
+ ics += `SUMMARY:${esc(r.title)}\r\n`;
+
+ let desc = r.type ? `Type: ${r.type}` : '';
+ if (r.confirmation_number) desc += `\\nConfirmation: ${r.confirmation_number}`;
+ if (meta.airline) desc += `\\nAirline: ${meta.airline}`;
+ if (meta.flight_number) desc += `\\nFlight: ${meta.flight_number}`;
+ if (meta.departure_airport) desc += `\\nFrom: ${meta.departure_airport}`;
+ if (meta.arrival_airport) desc += `\\nTo: ${meta.arrival_airport}`;
+ if (meta.train_number) desc += `\\nTrain: ${meta.train_number}`;
+ if (r.notes) desc += `\\n${r.notes}`;
+ if (desc) ics += `DESCRIPTION:${desc}\r\n`;
+ if (r.location) ics += `LOCATION:${esc(r.location)}\r\n`;
+ ics += `END:VEVENT\r\n`;
+ }
+
+ ics += 'END:VCALENDAR\r\n';
+
+ res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
+ res.setHeader('Content-Disposition', `attachment; filename="${esc(trip.title || 'trek-trip')}.ics"`);
+ res.send(ics);
+});
+
export default router;
diff --git a/server/src/routes/vacay.ts b/server/src/routes/vacay.ts
index 0dd6ba9..ee03496 100644
--- a/server/src/routes/vacay.ts
+++ b/server/src/routes/vacay.ts
@@ -349,6 +349,11 @@ router.post('/invite', (req: Request, res: Response) => {
});
} catch { /* websocket not available */ }
+ // Notify invited user
+ import('../services/notifications').then(({ notify }) => {
+ notify({ userId: user_id, event: 'vacay_invite', params: { actor: authReq.user.username } }).catch(() => {});
+ });
+
res.json({ success: true });
});
diff --git a/server/src/scheduler.ts b/server/src/scheduler.ts
index 2a8a75d..a3272c2 100644
--- a/server/src/scheduler.ts
+++ b/server/src/scheduler.ts
@@ -8,30 +8,48 @@ const backupsDir = path.join(dataDir, 'backups');
const uploadsDir = path.join(__dirname, '../uploads');
const settingsFile = path.join(dataDir, 'backup-settings.json');
-const CRON_EXPRESSIONS: Record = {
- hourly: '0 * * * *',
- daily: '0 2 * * *',
- weekly: '0 2 * * 0',
- monthly: '0 2 1 * *',
-};
-
-const VALID_INTERVALS = Object.keys(CRON_EXPRESSIONS);
+const VALID_INTERVALS = ['hourly', 'daily', 'weekly', 'monthly'];
+const VALID_DAYS_OF_WEEK = [0, 1, 2, 3, 4, 5, 6]; // 0=Sunday
+const VALID_HOURS = Array.from({ length: 24 }, (_, i) => i);
interface BackupSettings {
enabled: boolean;
interval: string;
keep_days: number;
+ hour: number;
+ day_of_week: number;
+ day_of_month: number;
+}
+
+function buildCronExpression(settings: BackupSettings): string {
+ const hour = VALID_HOURS.includes(settings.hour) ? settings.hour : 2;
+ const dow = VALID_DAYS_OF_WEEK.includes(settings.day_of_week) ? settings.day_of_week : 0;
+ const dom = settings.day_of_month >= 1 && settings.day_of_month <= 28 ? settings.day_of_month : 1;
+
+ switch (settings.interval) {
+ case 'hourly': return '0 * * * *';
+ case 'daily': return `0 ${hour} * * *`;
+ case 'weekly': return `0 ${hour} * * ${dow}`;
+ case 'monthly': return `0 ${hour} ${dom} * *`;
+ default: return `0 ${hour} * * *`;
+ }
}
let currentTask: ScheduledTask | null = null;
+function getDefaults(): BackupSettings {
+ return { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 };
+}
+
function loadSettings(): BackupSettings {
+ let settings = getDefaults();
try {
if (fs.existsSync(settingsFile)) {
- return JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
+ const saved = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
+ settings = { ...settings, ...saved };
}
} catch (e) {}
- return { enabled: false, interval: 'daily', keep_days: 7 };
+ return settings;
}
function saveSettings(settings: BackupSettings): void {
@@ -104,9 +122,10 @@ function start(): void {
return;
}
- const expression = CRON_EXPRESSIONS[settings.interval] || CRON_EXPRESSIONS.daily;
- currentTask = cron.schedule(expression, runBackup);
- console.log(`[Auto-Backup] Scheduled: ${settings.interval} (${expression}), retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`);
+ const expression = buildCronExpression(settings);
+ const tz = process.env.TZ || 'UTC';
+ currentTask = cron.schedule(expression, runBackup, { timezone: tz });
+ console.log(`[Auto-Backup] Scheduled: ${settings.interval} (${expression}), tz: ${tz}, retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`);
}
// Demo mode: hourly reset of demo user data
diff --git a/server/src/services/apiKeyCrypto.ts b/server/src/services/apiKeyCrypto.ts
new file mode 100644
index 0000000..881b840
--- /dev/null
+++ b/server/src/services/apiKeyCrypto.ts
@@ -0,0 +1,43 @@
+import * as crypto from 'crypto';
+import { JWT_SECRET } from '../config';
+
+const ENCRYPTED_PREFIX = 'enc:v1:';
+
+function get_key() {
+ return crypto.createHash('sha256').update(`${JWT_SECRET}:api_keys:v1`).digest();
+}
+
+export function encrypt_api_key(plain: unknown) {
+ const iv = crypto.randomBytes(12);
+ const cipher = crypto.createCipheriv('aes-256-gcm', get_key(), iv);
+ const enc = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
+ const tag = cipher.getAuthTag();
+ const blob = Buffer.concat([iv, tag, enc]).toString('base64');
+ return `${ENCRYPTED_PREFIX}${blob}`;
+}
+
+export function decrypt_api_key(value: unknown) {
+ if (!value) return null;
+ if (typeof value !== 'string') return null;
+ if (!value.startsWith(ENCRYPTED_PREFIX)) return value; // legacy plaintext
+ const blob = value.slice(ENCRYPTED_PREFIX.length);
+ try {
+ const buf = Buffer.from(blob, 'base64');
+ const iv = buf.subarray(0, 12);
+ const tag = buf.subarray(12, 28);
+ const enc = buf.subarray(28);
+ const decipher = crypto.createDecipheriv('aes-256-gcm', get_key(), iv);
+ decipher.setAuthTag(tag);
+ return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8');
+ } catch {
+ return null;
+ }
+}
+
+export function maybe_encrypt_api_key(value: unknown) {
+ const trimmed = String(value || '').trim();
+ if (!trimmed) return null;
+ if (trimmed.startsWith(ENCRYPTED_PREFIX)) return trimmed;
+ return encrypt_api_key(trimmed);
+}
+
diff --git a/server/src/services/auditLog.ts b/server/src/services/auditLog.ts
new file mode 100644
index 0000000..ed78ad5
--- /dev/null
+++ b/server/src/services/auditLog.ts
@@ -0,0 +1,30 @@
+import { Request } from 'express';
+import { db } from '../db/database';
+
+export function getClientIp(req: Request): string | null {
+ const xff = req.headers['x-forwarded-for'];
+ if (typeof xff === 'string') {
+ const first = xff.split(',')[0]?.trim();
+ return first || null;
+ }
+ if (Array.isArray(xff) && xff[0]) return String(xff[0]).trim() || null;
+ return req.socket?.remoteAddress || null;
+}
+
+/** Best-effort; never throws — failures are logged only. */
+export function writeAudit(entry: {
+ userId: number | null;
+ action: string;
+ resource?: string | null;
+ details?: Record;
+ ip?: string | null;
+}): void {
+ try {
+ const detailsJson = entry.details && Object.keys(entry.details).length > 0 ? JSON.stringify(entry.details) : null;
+ db.prepare(
+ `INSERT INTO audit_log (user_id, action, resource, details, ip) VALUES (?, ?, ?, ?, ?)`
+ ).run(entry.userId, entry.action, entry.resource ?? null, detailsJson, entry.ip ?? null);
+ } catch (e) {
+ console.error('[audit] write failed:', e instanceof Error ? e.message : e);
+ }
+}
diff --git a/server/src/services/notifications.ts b/server/src/services/notifications.ts
new file mode 100644
index 0000000..86884b4
--- /dev/null
+++ b/server/src/services/notifications.ts
@@ -0,0 +1,299 @@
+import nodemailer from 'nodemailer';
+import fetch from 'node-fetch';
+import { db } from '../db/database';
+
+// ── Types ──────────────────────────────────────────────────────────────────
+
+type EventType = 'trip_invite' | 'booking_change' | 'trip_reminder' | 'vacay_invite' | 'photos_shared' | 'collab_message' | 'packing_tagged';
+
+interface NotificationPayload {
+ userId: number;
+ event: EventType;
+ params: Record;
+}
+
+interface SmtpConfig {
+ host: string;
+ port: number;
+ user: string;
+ pass: string;
+ from: string;
+ secure: boolean;
+}
+
+// ── Settings helpers ───────────────────────────────────────────────────────
+
+function getAppSetting(key: string): string | null {
+ return (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null;
+}
+
+function getSmtpConfig(): SmtpConfig | null {
+ const host = process.env.SMTP_HOST || getAppSetting('smtp_host');
+ const port = process.env.SMTP_PORT || getAppSetting('smtp_port');
+ const user = process.env.SMTP_USER || getAppSetting('smtp_user');
+ const pass = process.env.SMTP_PASS || getAppSetting('smtp_pass');
+ const from = process.env.SMTP_FROM || getAppSetting('smtp_from');
+ if (!host || !port || !from) return null;
+ return { host, port: parseInt(port, 10), user: user || '', pass: pass || '', from, secure: parseInt(port, 10) === 465 };
+}
+
+function getWebhookUrl(): string | null {
+ return process.env.NOTIFICATION_WEBHOOK_URL || getAppSetting('notification_webhook_url');
+}
+
+function getAppUrl(): string {
+ return process.env.APP_URL || getAppSetting('app_url') || '';
+}
+
+function getUserEmail(userId: number): string | null {
+ return (db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined)?.email || null;
+}
+
+function getUserLanguage(userId: number): string {
+ return (db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'language'").get(userId) as { value: string } | undefined)?.value || 'en';
+}
+
+function getUserPrefs(userId: number): Record {
+ const row = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId) as any;
+ return row || { notify_trip_invite: 1, notify_booking_change: 1, notify_trip_reminder: 1, notify_vacay_invite: 1, notify_photos_shared: 1, notify_collab_message: 1, notify_packing_tagged: 1, notify_webhook: 0 };
+}
+
+// Event → preference column mapping
+const EVENT_PREF_MAP: Record = {
+ trip_invite: 'notify_trip_invite',
+ booking_change: 'notify_booking_change',
+ trip_reminder: 'notify_trip_reminder',
+ vacay_invite: 'notify_vacay_invite',
+ photos_shared: 'notify_photos_shared',
+ collab_message: 'notify_collab_message',
+ packing_tagged: 'notify_packing_tagged',
+};
+
+// ── Email i18n strings ─────────────────────────────────────────────────────
+
+interface EmailStrings { footer: string; manage: string; madeWith: string; openTrek: string }
+
+const I18N: Record = {
+ en: { footer: 'You received this because you have notifications enabled in TREK.', manage: 'Manage preferences in Settings', madeWith: 'Made with', openTrek: 'Open TREK' },
+ de: { footer: 'Du erhältst diese E-Mail, weil du Benachrichtigungen in TREK aktiviert hast.', manage: 'Einstellungen verwalten', madeWith: 'Made with', openTrek: 'TREK öffnen' },
+ fr: { footer: 'Vous recevez cet e-mail car les notifications sont activées dans TREK.', manage: 'Gérer les préférences', madeWith: 'Made with', openTrek: 'Ouvrir TREK' },
+ es: { footer: 'Recibiste esto porque tienes las notificaciones activadas en TREK.', manage: 'Gestionar preferencias', madeWith: 'Made with', openTrek: 'Abrir TREK' },
+ nl: { footer: 'Je ontvangt dit omdat je meldingen hebt ingeschakeld in TREK.', manage: 'Voorkeuren beheren', madeWith: 'Made with', openTrek: 'TREK openen' },
+ ru: { footer: 'Вы получили это, потому что у вас включены уведомления в TREK.', manage: 'Управление настройками', madeWith: 'Made with', openTrek: 'Открыть TREK' },
+ zh: { footer: '您收到此邮件是因为您在 TREK 中启用了通知。', manage: '管理偏好设置', madeWith: 'Made with', openTrek: '打开 TREK' },
+ ar: { footer: 'تلقيت هذا لأنك قمت بتفعيل الإشعارات في TREK.', manage: 'إدارة التفضيلات', madeWith: 'Made with', openTrek: 'فتح TREK' },
+};
+
+// Translated notification texts per event type
+interface EventText { title: string; body: string }
+type EventTextFn = (params: Record) => EventText
+
+const EVENT_TEXTS: Record> = {
+ en: {
+ trip_invite: p => ({ title: `You've been invited to "${p.trip}"`, body: `${p.actor} invited you to the trip "${p.trip}". Open TREK to view and start planning!` }),
+ booking_change: p => ({ title: `New booking: ${p.booking}`, body: `${p.actor} added a new ${p.type} "${p.booking}" to "${p.trip}".` }),
+ trip_reminder: p => ({ title: `Trip reminder: ${p.trip}`, body: `Your trip "${p.trip}" is coming up soon!` }),
+ vacay_invite: p => ({ title: 'Vacay Fusion Invite', body: `${p.actor} invited you to fuse vacation plans. Open TREK to accept or decline.` }),
+ photos_shared: p => ({ title: `${p.count} photos shared`, body: `${p.actor} shared ${p.count} photo(s) in "${p.trip}".` }),
+ collab_message: p => ({ title: `New message in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
+ packing_tagged: p => ({ title: `Packing: ${p.category}`, body: `${p.actor} assigned you to the "${p.category}" packing category in "${p.trip}".` }),
+ },
+ de: {
+ trip_invite: p => ({ title: `Einladung zu "${p.trip}"`, body: `${p.actor} hat dich zur Reise "${p.trip}" eingeladen. Öffne TREK um die Planung zu starten!` }),
+ booking_change: p => ({ title: `Neue Buchung: ${p.booking}`, body: `${p.actor} hat eine neue Buchung "${p.booking}" (${p.type}) zu "${p.trip}" hinzugefügt.` }),
+ trip_reminder: p => ({ title: `Reiseerinnerung: ${p.trip}`, body: `Deine Reise "${p.trip}" steht bald an!` }),
+ vacay_invite: p => ({ title: 'Vacay Fusion-Einladung', body: `${p.actor} hat dich eingeladen, Urlaubspläne zu fusionieren. Öffne TREK um anzunehmen oder abzulehnen.` }),
+ photos_shared: p => ({ title: `${p.count} Fotos geteilt`, body: `${p.actor} hat ${p.count} Foto(s) in "${p.trip}" geteilt.` }),
+ collab_message: p => ({ title: `Neue Nachricht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
+ packing_tagged: p => ({ title: `Packliste: ${p.category}`, body: `${p.actor} hat dich der Kategorie "${p.category}" in der Packliste von "${p.trip}" zugewiesen.` }),
+ },
+ fr: {
+ trip_invite: p => ({ title: `Invitation à "${p.trip}"`, body: `${p.actor} vous a invité au voyage "${p.trip}". Ouvrez TREK pour commencer la planification !` }),
+ booking_change: p => ({ title: `Nouvelle réservation : ${p.booking}`, body: `${p.actor} a ajouté une réservation "${p.booking}" (${p.type}) à "${p.trip}".` }),
+ trip_reminder: p => ({ title: `Rappel de voyage : ${p.trip}`, body: `Votre voyage "${p.trip}" approche !` }),
+ vacay_invite: p => ({ title: 'Invitation Vacay Fusion', body: `${p.actor} vous invite à fusionner les plans de vacances. Ouvrez TREK pour accepter ou refuser.` }),
+ photos_shared: p => ({ title: `${p.count} photos partagées`, body: `${p.actor} a partagé ${p.count} photo(s) dans "${p.trip}".` }),
+ collab_message: p => ({ title: `Nouveau message dans "${p.trip}"`, body: `${p.actor} : ${p.preview}` }),
+ packing_tagged: p => ({ title: `Bagages : ${p.category}`, body: `${p.actor} vous a assigné à la catégorie "${p.category}" dans "${p.trip}".` }),
+ },
+ es: {
+ trip_invite: p => ({ title: `Invitación a "${p.trip}"`, body: `${p.actor} te invitó al viaje "${p.trip}". ¡Abre TREK para comenzar a planificar!` }),
+ booking_change: p => ({ title: `Nueva reserva: ${p.booking}`, body: `${p.actor} añadió una reserva "${p.booking}" (${p.type}) a "${p.trip}".` }),
+ trip_reminder: p => ({ title: `Recordatorio: ${p.trip}`, body: `¡Tu viaje "${p.trip}" se acerca!` }),
+ vacay_invite: p => ({ title: 'Invitación Vacay Fusion', body: `${p.actor} te invitó a fusionar planes de vacaciones. Abre TREK para aceptar o rechazar.` }),
+ photos_shared: p => ({ title: `${p.count} fotos compartidas`, body: `${p.actor} compartió ${p.count} foto(s) en "${p.trip}".` }),
+ collab_message: p => ({ title: `Nuevo mensaje en "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
+ packing_tagged: p => ({ title: `Equipaje: ${p.category}`, body: `${p.actor} te asignó a la categoría "${p.category}" en "${p.trip}".` }),
+ },
+ nl: {
+ trip_invite: p => ({ title: `Uitgenodigd voor "${p.trip}"`, body: `${p.actor} heeft je uitgenodigd voor de reis "${p.trip}". Open TREK om te beginnen met plannen!` }),
+ booking_change: p => ({ title: `Nieuwe boeking: ${p.booking}`, body: `${p.actor} heeft een boeking "${p.booking}" (${p.type}) toegevoegd aan "${p.trip}".` }),
+ trip_reminder: p => ({ title: `Reisherinnering: ${p.trip}`, body: `Je reis "${p.trip}" komt eraan!` }),
+ vacay_invite: p => ({ title: 'Vacay Fusion uitnodiging', body: `${p.actor} nodigt je uit om vakantieplannen te fuseren. Open TREK om te accepteren of af te wijzen.` }),
+ photos_shared: p => ({ title: `${p.count} foto's gedeeld`, body: `${p.actor} heeft ${p.count} foto('s) gedeeld in "${p.trip}".` }),
+ collab_message: p => ({ title: `Nieuw bericht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
+ packing_tagged: p => ({ title: `Paklijst: ${p.category}`, body: `${p.actor} heeft je toegewezen aan de categorie "${p.category}" in "${p.trip}".` }),
+ },
+ ru: {
+ trip_invite: p => ({ title: `Приглашение в "${p.trip}"`, body: `${p.actor} пригласил вас в поездку "${p.trip}". Откройте TREK чтобы начать планирование!` }),
+ booking_change: p => ({ title: `Новое бронирование: ${p.booking}`, body: `${p.actor} добавил бронирование "${p.booking}" (${p.type}) в "${p.trip}".` }),
+ trip_reminder: p => ({ title: `Напоминание: ${p.trip}`, body: `Ваша поездка "${p.trip}" скоро начнётся!` }),
+ vacay_invite: p => ({ title: 'Приглашение Vacay Fusion', body: `${p.actor} приглашает вас объединить планы отпуска. Откройте TREK для подтверждения.` }),
+ photos_shared: p => ({ title: `${p.count} фото`, body: `${p.actor} поделился ${p.count} фото в "${p.trip}".` }),
+ collab_message: p => ({ title: `Новое сообщение в "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
+ packing_tagged: p => ({ title: `Список вещей: ${p.category}`, body: `${p.actor} назначил вас в категорию "${p.category}" в "${p.trip}".` }),
+ },
+ zh: {
+ trip_invite: p => ({ title: `邀请加入"${p.trip}"`, body: `${p.actor} 邀请你加入旅行"${p.trip}"。打开 TREK 开始规划!` }),
+ booking_change: p => ({ title: `新预订:${p.booking}`, body: `${p.actor} 在"${p.trip}"中添加了预订"${p.booking}"(${p.type})。` }),
+ trip_reminder: p => ({ title: `旅行提醒:${p.trip}`, body: `你的旅行"${p.trip}"即将开始!` }),
+ vacay_invite: p => ({ title: 'Vacay 融合邀请', body: `${p.actor} 邀请你合并假期计划。打开 TREK 接受或拒绝。` }),
+ photos_shared: p => ({ title: `${p.count} 张照片已分享`, body: `${p.actor} 在"${p.trip}"中分享了 ${p.count} 张照片。` }),
+ collab_message: p => ({ title: `"${p.trip}"中的新消息`, body: `${p.actor}:${p.preview}` }),
+ packing_tagged: p => ({ title: `行李清单:${p.category}`, body: `${p.actor} 将你分配到"${p.trip}"中的"${p.category}"类别。` }),
+ },
+ ar: {
+ trip_invite: p => ({ title: `دعوة إلى "${p.trip}"`, body: `${p.actor} دعاك إلى الرحلة "${p.trip}". افتح TREK لبدء التخطيط!` }),
+ booking_change: p => ({ title: `حجز جديد: ${p.booking}`, body: `${p.actor} أضاف حجز "${p.booking}" (${p.type}) إلى "${p.trip}".` }),
+ trip_reminder: p => ({ title: `تذكير: ${p.trip}`, body: `رحلتك "${p.trip}" تقترب!` }),
+ vacay_invite: p => ({ title: 'دعوة دمج الإجازة', body: `${p.actor} يدعوك لدمج خطط الإجازة. افتح TREK للقبول أو الرفض.` }),
+ photos_shared: p => ({ title: `${p.count} صور مشتركة`, body: `${p.actor} شارك ${p.count} صورة في "${p.trip}".` }),
+ collab_message: p => ({ title: `رسالة جديدة في "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
+ packing_tagged: p => ({ title: `قائمة التعبئة: ${p.category}`, body: `${p.actor} عيّنك في فئة "${p.category}" في "${p.trip}".` }),
+ },
+};
+
+// Get localized event text
+function getEventText(lang: string, event: EventType, params: Record): EventText {
+ const texts = EVENT_TEXTS[lang] || EVENT_TEXTS.en;
+ return texts[event](params);
+}
+
+// ── Email HTML builder ─────────────────────────────────────────────────────
+
+function buildEmailHtml(subject: string, body: string, lang: string): string {
+ const s = I18N[lang] || I18N.en;
+ const appUrl = getAppUrl();
+ const ctaHref = appUrl || '#';
+
+ return `
+
+
+
+
+
+
+
+
+
+ TREK
+ Travel Resource & Exploration Kit
+ |
+
+
+ ${subject}
+
+ ${body}
+ |
+
+ ${appUrl ? `|
+ ${s.openTrek}
+ | ` : ''}
+
+ |
+ ${s.footer} ${s.manage}
+ ${s.madeWith} ♥ by Maurice · GitHub
+ |
+
+ |
+
+
+`;
+}
+
+// ── Send functions ─────────────────────────────────────────────────────────
+
+async function sendEmail(to: string, subject: string, body: string, userId?: number): Promise {
+ const config = getSmtpConfig();
+ if (!config) return false;
+
+ const lang = userId ? getUserLanguage(userId) : 'en';
+
+ try {
+ const skipTls = process.env.SMTP_SKIP_TLS_VERIFY === 'true' || getAppSetting('smtp_skip_tls_verify') === 'true';
+ const transporter = nodemailer.createTransport({
+ host: config.host,
+ port: config.port,
+ secure: config.secure,
+ auth: config.user ? { user: config.user, pass: config.pass } : undefined,
+ ...(skipTls ? { tls: { rejectUnauthorized: false } } : {}),
+ });
+
+ await transporter.sendMail({
+ from: config.from,
+ to,
+ subject: `TREK — ${subject}`,
+ text: body,
+ html: buildEmailHtml(subject, body, lang),
+ });
+ return true;
+ } catch (err) {
+ console.error('[Notifications] Email send failed:', err instanceof Error ? err.message : err);
+ return false;
+ }
+}
+
+async function sendWebhook(payload: { event: string; title: string; body: string; tripName?: string }): Promise {
+ const url = getWebhookUrl();
+ if (!url) return false;
+
+ try {
+ await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ...payload, timestamp: new Date().toISOString(), source: 'TREK' }),
+ signal: AbortSignal.timeout(10000),
+ });
+ return true;
+ } catch (err) {
+ console.error('[Notifications] Webhook failed:', err instanceof Error ? err.message : err);
+ return false;
+ }
+}
+
+// ── Public API ─────────────────────────────────────────────────────────────
+
+export async function notify(payload: NotificationPayload): Promise {
+ const prefs = getUserPrefs(payload.userId);
+ const prefKey = EVENT_PREF_MAP[payload.event];
+ if (prefKey && !prefs[prefKey]) return;
+
+ const lang = getUserLanguage(payload.userId);
+ const { title, body } = getEventText(lang, payload.event, payload.params);
+
+ const email = getUserEmail(payload.userId);
+ if (email) await sendEmail(email, title, body, payload.userId);
+ if (prefs.notify_webhook) await sendWebhook({ event: payload.event, title, body, tripName: payload.params.trip });
+}
+
+export async function notifyTripMembers(tripId: number, actorUserId: number, event: EventType, params: Record): Promise {
+ const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined;
+ if (!trip) return;
+
+ const members = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(tripId) as { user_id: number }[];
+ const allIds = [trip.user_id, ...members.map(m => m.user_id)].filter(id => id !== actorUserId);
+ const unique = [...new Set(allIds)];
+
+ for (const userId of unique) {
+ await notify({ userId, event, params });
+ }
+}
+
+export async function testSmtp(to: string): Promise<{ success: boolean; error?: string }> {
+ try {
+ const sent = await sendEmail(to, 'Test Notification', 'This is a test email from TREK. If you received this, your SMTP configuration is working correctly.');
+ return sent ? { success: true } : { success: false, error: 'SMTP not configured' };
+ } catch (err) {
+ return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
+ }
+}
diff --git a/server/src/types.ts b/server/src/types.ts
index 495b8c4..7db6a71 100644
--- a/server/src/types.ts
+++ b/server/src/types.ts
@@ -15,6 +15,7 @@ export interface User {
last_login?: string | null;
mfa_enabled?: number | boolean;
mfa_secret?: string | null;
+ mfa_backup_codes?: string | null;
created_at?: string;
updated_at?: string;
}
diff --git a/server/src/websocket.ts b/server/src/websocket.ts
index 1e28ddb..2f6e44c 100644
--- a/server/src/websocket.ts
+++ b/server/src/websocket.ts
@@ -55,12 +55,18 @@ function setupWebSocket(server: http.Server): void {
try {
const decoded = jwt.verify(token, JWT_SECRET) as { id: number };
user = db.prepare(
- 'SELECT id, username, email, role FROM users WHERE id = ?'
+ 'SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?'
).get(decoded.id) as User | undefined;
if (!user) {
nws.close(4001, 'User not found');
return;
}
+ const requireMfa = (db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined)?.value === 'true';
+ const mfaOk = user.mfa_enabled === 1 || user.mfa_enabled === true;
+ if (requireMfa && !mfaOk) {
+ nws.close(4403, 'MFA required');
+ return;
+ }
} catch (err: unknown) {
nws.close(4001, 'Invalid or expired token');
return;
diff --git a/server/tsconfig.json b/server/tsconfig.json
index 0ecff37..b443a5b 100644
--- a/server/tsconfig.json
+++ b/server/tsconfig.json
@@ -14,7 +14,14 @@
"sourceMap": true,
"allowJs": true,
"noUnusedLocals": false,
- "noUnusedParameters": false
+ "noUnusedParameters": false,
+ // The MCP SDK's package.json uses a wildcard exports pattern with extension-less targets
+ // (e.g. "./*": "./dist/esm/*") which TypeScript cannot resolve — it only strips .js suffixes.
+ // These paths manually redirect to the CJS dist until the SDK fixes its exports map.
+ "paths": {
+ "@modelcontextprotocol/sdk/server/mcp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp"],
+ "@modelcontextprotocol/sdk/server/streamableHttp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp"]
+ }
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
|