From d69585a820556b5b038b36527955c3d7fc69e9c9 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 28 Mar 2026 22:23:34 +0100 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20add=20Unraid=20Community=20App=20?= =?UTF-8?q?template=20=E2=80=94=20fixes=20#56?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- unraid-template.xml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 unraid-template.xml diff --git a/unraid-template.xml b/unraid-template.xml new file mode 100644 index 0000000..8fe29a1 --- /dev/null +++ b/unraid-template.xml @@ -0,0 +1,30 @@ + + + TREK + mauriceboe/trek + https://hub.docker.com/r/mauriceboe/trek + + latest + Latest stable release + + bridge + false + https://github.com/mauriceboe/TREK/issues + https://github.com/mauriceboe/TREK + TREK is a self-hosted, real-time collaborative travel planner with interactive maps, budgets, bookings, packing lists, file management, and more. Plan trips together with your group — changes sync instantly across all connected users. Includes OIDC/SSO support, dark mode, PWA, and a modular addon system (Vacay, Atlas, Collab, Budget, Packing). + Productivity: Tools: + http://[IP]:[PORT:3000] + https://raw.githubusercontent.com/mauriceboe/TREK/main/unraid-template.xml + https://raw.githubusercontent.com/mauriceboe/TREK/main/client/public/icons/icon-dark.svg + + + Support TREK development + https://ko-fi.com/mauriceboe + + 3000 + /mnt/user/appdata/trek/data + /mnt/user/appdata/trek/uploads + production + + 3000 + From 0d9dbb62861bd1008848538ca9122633e1317ddc Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 28 Mar 2026 23:00:53 +0100 Subject: [PATCH 02/13] i18n: consolidate es.js into es.ts, add missing 2.6.2 Spanish translations --- client/src/i18n/translations/es.js | 1061 -------------------------- client/src/i18n/translations/es.ts | 1114 +++++++++++++++++++++++++++- 2 files changed, 1113 insertions(+), 1062 deletions(-) delete mode 100644 client/src/i18n/translations/es.js diff --git a/client/src/i18n/translations/es.js b/client/src/i18n/translations/es.js deleted file mode 100644 index 2146dfb..0000000 --- a/client/src/i18n/translations/es.js +++ /dev/null @@ -1,1061 +0,0 @@ -const es = { - // Common - 'common.save': 'Guardar', - 'common.cancel': 'Cancelar', - 'common.delete': 'Eliminar', - 'common.edit': 'Editar', - 'common.add': 'Añadir', - 'common.loading': 'Cargando...', - 'common.error': 'Error', - 'common.back': 'Atrás', - 'common.all': 'Todo', - 'common.close': 'Cerrar', - 'common.open': 'Abrir', - 'common.upload': 'Subir', - 'common.search': 'Buscar', - 'common.confirm': 'Confirmar', - 'common.ok': 'Aceptar', - 'common.yes': 'Sí', - 'common.no': 'No', - 'common.or': 'o', - 'common.none': 'Ninguno', - 'common.date': 'Fecha', - 'common.rename': 'Renombrar', - 'common.name': 'Nombre', - 'common.email': 'Correo', - 'common.password': 'Contraseña', - 'common.saving': 'Guardando...', - 'common.update': 'Actualizar', - 'common.change': 'Cambiar', - 'common.uploading': 'Subiendo…', - 'common.backToPlanning': 'Volver a la planificación', - 'common.reset': 'Restablecer', - - // Navbar - 'nav.trip': 'Viaje', - 'nav.share': 'Compartir', - 'nav.settings': 'Ajustes', - 'nav.admin': 'Administración', - 'nav.logout': 'Cerrar sesión', - 'nav.lightMode': 'Modo claro', - 'nav.darkMode': 'Modo oscuro', - 'nav.autoMode': 'Modo automático', - 'nav.administrator': 'Administrador', - 'nav.myTrips': 'Mis viajes', - - // Dashboard - 'dashboard.title': 'Mis viajes', - 'dashboard.subtitle.loading': 'Cargando viajes...', - 'dashboard.subtitle.trips': '{count} viajes ({archived} archivados)', - 'dashboard.subtitle.empty': 'Empieza tu primer viaje', - 'dashboard.subtitle.activeOne': '{count} viaje activo', - 'dashboard.subtitle.activeMany': '{count} viajes activos', - 'dashboard.subtitle.archivedSuffix': ' · {count} archivados', - 'dashboard.newTrip': 'Nuevo viaje', - 'dashboard.currency': 'Divisa', - 'dashboard.timezone': 'Zonas horarias', - 'dashboard.localTime': 'Hora local', - 'dashboard.emptyTitle': 'Aún no hay viajes', - 'dashboard.emptyText': 'Crea tu primer viaje y empieza a planificar', - 'dashboard.emptyButton': 'Crear primer viaje', - 'dashboard.nextTrip': 'Próximo viaje', - 'dashboard.shared': 'Compartido', - 'dashboard.sharedBy': 'Compartido por {name}', - 'dashboard.days': 'Días', - 'dashboard.places': 'Lugares', - 'dashboard.archive': 'Archivar', - 'dashboard.restore': 'Restaurar', - 'dashboard.archived': 'Archivado', - 'dashboard.status.ongoing': 'En curso', - 'dashboard.status.today': 'Hoy', - 'dashboard.status.tomorrow': 'Mañana', - 'dashboard.status.past': 'Pasado', - 'dashboard.status.daysLeft': 'Quedan {count} días', - 'dashboard.toast.loadError': 'No se pudieron cargar los viajes', - 'dashboard.toast.created': '¡Viaje creado correctamente!', - 'dashboard.toast.createError': 'No se pudo crear el viaje', - 'dashboard.toast.updated': '¡Viaje actualizado!', - 'dashboard.toast.updateError': 'No se pudo actualizar el viaje', - 'dashboard.toast.deleted': 'Viaje eliminado', - 'dashboard.toast.deleteError': 'No se pudo eliminar el viaje', - 'dashboard.toast.archived': 'Viaje archivado', - 'dashboard.toast.archiveError': 'No se pudo archivar el viaje', - 'dashboard.toast.restored': 'Viaje restaurado', - 'dashboard.toast.restoreError': 'No se pudo restaurar el viaje', - 'dashboard.confirm.delete': '¿Eliminar el viaje "{title}"? Todos los lugares y planes se borrarán permanentemente.', - 'dashboard.editTrip': 'Editar viaje', - 'dashboard.createTrip': 'Crear nuevo viaje', - 'dashboard.tripTitle': 'Título', - 'dashboard.tripTitlePlaceholder': 'p. ej. Verano en Japón', - 'dashboard.tripDescription': 'Descripción', - 'dashboard.tripDescriptionPlaceholder': '¿De qué trata este viaje?', - 'dashboard.startDate': 'Fecha de inicio', - 'dashboard.endDate': 'Fecha de fin', - 'dashboard.noDateHint': 'Sin fecha definida: se crearán 7 días por defecto. Puedes cambiarlo cuando quieras.', - 'dashboard.coverImage': 'Imagen de portada', - 'dashboard.addCoverImage': 'Añadir imagen de portada', - 'dashboard.coverSaved': 'Imagen de portada guardada', - 'dashboard.coverUploadError': 'Error al subir la imagen', - 'dashboard.coverRemoveError': 'Error al eliminar la imagen', - 'dashboard.titleRequired': 'El título es obligatorio', - 'dashboard.endDateError': 'La fecha de fin debe ser posterior a la de inicio', - - // Settings - 'settings.title': 'Ajustes', - 'settings.subtitle': 'Configura tus ajustes personales', - 'settings.map': 'Mapa', - 'settings.mapTemplate': 'Plantilla del mapa', - 'settings.mapTemplatePlaceholder.select': 'Seleccionar plantilla...', - 'settings.mapDefaultHint': 'Déjalo vacío para OpenStreetMap (por defecto)', - 'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', - 'settings.mapHint': 'Plantilla de URL para los mosaicos del mapa', - 'settings.latitude': 'Latitud', - 'settings.longitude': 'Longitud', - 'settings.saveMap': 'Guardar mapa', - 'settings.apiKeys': 'Claves API', - 'settings.mapsKey': 'Clave API de Google Maps', - 'settings.mapsKeyHint': 'Necesaria para buscar lugares. Consíguela en console.cloud.google.com', - 'settings.weatherKey': 'Clave API de OpenWeatherMap', - 'settings.weatherKeyHint': 'Para datos meteorológicos. Gratis en openweathermap.org/api', - 'settings.keyPlaceholder': 'Introduce la clave...', - 'settings.configured': 'Configurado', - 'settings.saveKeys': 'Guardar claves', - 'settings.display': 'Visualización', - 'settings.colorMode': 'Modo de color', - 'settings.light': 'Claro', - 'settings.dark': 'Oscuro', - 'settings.auto': 'Automático', - 'settings.language': 'Idioma', - 'settings.temperature': 'Unidad de temperatura', - 'settings.timeFormat': 'Formato de hora', - 'settings.routeCalculation': 'Cálculo de ruta', - 'settings.on': 'Activado', - 'settings.off': 'Desactivado', - 'settings.account': 'Cuenta', - 'settings.username': 'Usuario', - 'settings.email': 'Correo', - 'settings.role': 'Rol', - 'settings.roleAdmin': 'Administrador', - 'settings.oidcLinked': 'Vinculado con', - 'settings.changePassword': 'Cambiar contraseña', - 'settings.currentPassword': 'Contraseña actual', - 'settings.newPassword': 'Nueva contraseña', - 'settings.confirmPassword': 'Confirmar nueva contraseña', - 'settings.updatePassword': 'Actualizar contraseña', - 'settings.passwordRequired': 'Introduce la contraseña actual y la nueva', - 'settings.passwordTooShort': 'La contraseña debe tener al menos 8 caracteres', - 'settings.passwordMismatch': 'Las contraseñas no coinciden', - 'settings.passwordChanged': 'Contraseña cambiada correctamente', - 'settings.deleteAccount': 'Eliminar cuenta', - 'settings.deleteAccountTitle': '¿Eliminar tu cuenta?', - 'settings.deleteAccountWarning': 'Tu cuenta y todos tus viajes, lugares y archivos se eliminarán permanentemente. Esta acción no se puede deshacer.', - 'settings.deleteAccountConfirm': 'Eliminar permanentemente', - 'settings.deleteBlockedTitle': 'No es posible eliminarla', - 'settings.deleteBlockedMessage': 'Eres el único administrador. Asciende a otro usuario a administrador antes de eliminar tu cuenta.', - 'settings.roleUser': 'Usuario', - 'settings.saveProfile': 'Guardar perfil', - 'settings.toast.mapSaved': 'Ajustes del mapa guardados', - 'settings.toast.keysSaved': 'Claves API guardadas', - 'settings.toast.displaySaved': 'Ajustes de visualización guardados', - 'settings.toast.profileSaved': 'Perfil guardado', - 'settings.uploadAvatar': 'Subir foto de perfil', - 'settings.removeAvatar': 'Eliminar foto de perfil', - 'settings.avatarUploaded': 'Foto de perfil actualizada', - 'settings.avatarRemoved': 'Foto de perfil eliminada', - 'settings.avatarError': 'Falló la subida', - - // Login - 'login.error': 'Inicio de sesión fallido. Revisa tus credenciales.', - 'login.tagline': 'Tus viajes.\nTu plan.', - 'login.description': 'Planifica viajes en colaboración con mapas interactivos, presupuestos y sincronización en tiempo real.', - 'login.features.maps': 'Mapas interactivos', - 'login.features.mapsDesc': 'Google Places, rutas y agrupación', - 'login.features.realtime': 'Sincronización en tiempo real', - 'login.features.realtimeDesc': 'Planificad juntos mediante WebSocket', - 'login.features.budget': 'Control de presupuesto', - 'login.features.budgetDesc': 'Categorías, gráficos y costes por persona', - 'login.features.collab': 'Colaboración', - 'login.features.collabDesc': 'Multiusuario con viajes compartidos', - 'login.features.packing': 'Listas de equipaje', - 'login.features.packingDesc': 'Categorías, progreso y sugerencias', - 'login.features.bookings': 'Reservas', - 'login.features.bookingsDesc': 'Vuelos, hoteles, restaurantes y más', - 'login.features.files': 'Documentos', - 'login.features.filesDesc': 'Sube y gestiona documentos', - 'login.features.routes': 'Rutas inteligentes', - 'login.features.routesDesc': 'Optimización automática y exportación a Google Maps', - 'login.selfHosted': 'Autoalojado · Código abierto · Tus datos siguen siendo tuyos', - 'login.title': 'Iniciar sesión', - 'login.subtitle': 'Bienvenido de nuevo', - 'login.signingIn': 'Iniciando sesión…', - 'login.signIn': 'Entrar', - 'login.createAdmin': 'Crear cuenta de administrador', - 'login.createAdminHint': 'Configura la primera cuenta administradora de NOMAD.', - 'login.createAccount': 'Crear cuenta', - 'login.createAccountHint': 'Crea una cuenta nueva.', - 'login.creating': 'Creando…', - 'login.noAccount': '¿No tienes cuenta?', - 'login.hasAccount': '¿Ya tienes cuenta?', - 'login.register': 'Registrarse', - 'login.emailPlaceholder': 'tu@correo.com', - 'login.username': 'Usuario', - 'login.oidc.registrationDisabled': 'El registro está desactivado. Contacta con tu administrador.', - 'login.oidc.noEmail': 'No se recibió ningún correo del proveedor.', - 'login.oidc.tokenFailed': 'La autenticación falló.', - 'login.oidc.invalidState': 'Sesión no válida. Inténtalo de nuevo.', - 'login.demoFailed': 'Falló el acceso a la demo', - 'login.oidcSignIn': 'Entrar con {name}', - 'login.demoHint': 'Prueba la demo: no necesitas registrarte', - - // Register - 'register.passwordMismatch': 'Las contraseñas no coinciden', - 'register.passwordTooShort': 'La contraseña debe tener al menos 6 caracteres', - 'register.failed': 'Falló el registro', - 'register.getStarted': 'Empezar', - 'register.subtitle': 'Crea una cuenta y empieza a planificar tus viajes.', - 'register.feature1': 'Planes de viaje ilimitados', - 'register.feature2': 'Vista de mapa interactiva', - 'register.feature3': 'Gestiona lugares y categorías', - 'register.feature4': 'Haz seguimiento de las reservas', - 'register.feature5': 'Crea listas de equipaje', - 'register.feature6': 'Guarda fotos y archivos', - 'register.createAccount': 'Crear cuenta', - 'register.startPlanning': 'Empieza a planificar tu viaje', - 'register.minChars': 'Mín. 6 caracteres', - 'register.confirmPassword': 'Confirmar contraseña', - 'register.repeatPassword': 'Repetir contraseña', - 'register.registering': 'Registrando...', - 'register.register': 'Registrarse', - 'register.hasAccount': '¿Ya tienes cuenta?', - 'register.signIn': 'Iniciar sesión', - - // Admin - 'admin.title': 'Administración', - 'admin.subtitle': 'Gestión de usuarios y ajustes del sistema', - 'admin.tabs.users': 'Usuarios', - 'admin.tabs.categories': 'Categorías', - 'admin.tabs.backup': 'Copia de seguridad', - 'admin.stats.users': 'Usuarios', - 'admin.stats.trips': 'Viajes', - 'admin.stats.places': 'Lugares', - 'admin.stats.photos': 'Fotos', - 'admin.stats.files': 'Archivos', - 'admin.table.user': 'Usuario', - 'admin.table.email': 'Correo', - 'admin.table.role': 'Rol', - 'admin.table.created': 'Creado', - 'admin.table.lastLogin': 'Último acceso', - 'admin.table.actions': 'Acciones', - 'admin.you': '(Tú)', - 'admin.editUser': 'Editar usuario', - 'admin.newPassword': 'Nueva contraseña', - 'admin.newPasswordHint': 'Déjalo vacío para mantener la contraseña actual', - 'admin.deleteUser': '¿Eliminar al usuario "{name}"? Todos sus viajes se borrarán permanentemente.', - 'admin.deleteUserTitle': 'Eliminar usuario', - 'admin.newPasswordPlaceholder': 'Introduce una nueva contraseña…', - 'admin.toast.loadError': 'No se pudieron cargar los datos de administración', - 'admin.toast.userUpdated': 'Usuario actualizado', - 'admin.toast.updateError': 'No se pudo actualizar', - 'admin.toast.userDeleted': 'Usuario eliminado', - 'admin.toast.deleteError': 'No se pudo eliminar', - 'admin.toast.cannotDeleteSelf': 'No puedes eliminar tu propia cuenta', - 'admin.toast.userCreated': 'Usuario creado', - 'admin.toast.createError': 'No se pudo crear el usuario', - 'admin.toast.fieldsRequired': 'Usuario, correo y contraseña son obligatorios', - 'admin.createUser': 'Crear usuario', - 'admin.tabs.settings': 'Ajustes', - 'admin.allowRegistration': 'Permitir el registro', - 'admin.allowRegistrationHint': 'Los nuevos usuarios pueden registrarse por sí mismos', - 'admin.apiKeys': 'Claves API', - 'admin.apiKeysHint': 'Opcional. Activa datos ampliados de lugares, como fotos y previsión del tiempo.', - 'admin.mapsKey': 'Clave API de Google Maps', - 'admin.mapsKeyHint': 'Obligatoria para buscar lugares. Consíguela en console.cloud.google.com', - 'admin.mapsKeyHintLong': 'Sin una clave API, la búsqueda de lugares usa OpenStreetMap. Con una clave de Google también se pueden cargar fotos, valoraciones y horarios de apertura. Consíguela en console.cloud.google.com.', - 'admin.recommended': 'Recomendado', - 'admin.weatherKey': 'Clave API de OpenWeatherMap', - 'admin.weatherKeyHint': 'Para datos meteorológicos. Gratis en openweathermap.org', - 'admin.validateKey': 'Probar', - 'admin.keyValid': 'Conectado', - 'admin.keyInvalid': 'No válida', - 'admin.keySaved': 'Claves API guardadas', - 'admin.oidcTitle': 'Inicio de sesión único (OIDC)', - 'admin.oidcSubtitle': 'Permite iniciar sesión mediante proveedores externos como Google, Apple, Authentik o Keycloak.', - 'admin.oidcDisplayName': 'Nombre visible', - 'admin.oidcIssuer': 'URL del emisor', - 'admin.oidcIssuerHint': 'La URL Issuer de OpenID Connect del proveedor. Ej.: https://accounts.google.com', - 'admin.oidcSaved': 'Configuración OIDC guardada', - - // File Types - 'admin.fileTypes': 'Tipos de archivo permitidos', - 'admin.fileTypesHint': 'Configura qué tipos de archivo pueden subir los usuarios.', - 'admin.fileTypesFormat': 'Extensiones separadas por comas (p. ej. jpg,png,pdf,doc). Usa * para permitir todos los tipos.', - 'admin.fileTypesSaved': 'Ajustes de tipos de archivo guardados', - - // Addons - 'admin.tabs.addons': 'Complementos', - 'admin.addons.title': 'Complementos', - 'admin.addons.subtitle': 'Activa o desactiva funciones para personalizar tu experiencia en NOMAD.', - 'admin.addons.subtitleBefore': 'Activa o desactiva funciones para personalizar tu experiencia en ', - 'admin.addons.subtitleAfter': '.', - 'admin.addons.enabled': 'Activo', - 'admin.addons.disabled': 'Desactivado', - 'admin.addons.type.trip': 'Viaje', - 'admin.addons.type.global': 'Global', - 'admin.addons.tripHint': 'Disponible como pestaña dentro de cada viaje', - 'admin.addons.globalHint': 'Disponible como sección independiente en la navegación principal', - 'admin.addons.toast.updated': 'Complemento actualizado', - 'admin.addons.toast.error': 'No se pudo actualizar el complemento', - 'admin.addons.noAddons': 'No hay complementos disponibles', - 'admin.weather.title': 'Datos meteorológicos', - 'admin.weather.badge': 'Desde el 24 de marzo de 2026', - 'admin.weather.description': 'NOMAD utiliza Open-Meteo como fuente de datos meteorológicos. Open-Meteo es un servicio meteorológico gratuito y de código abierto: no requiere clave API.', - 'admin.weather.forecast': 'Pronóstico de 16 días', - 'admin.weather.forecastDesc': 'Antes eran 5 días (OpenWeatherMap)', - 'admin.weather.climate': 'Datos climáticos históricos', - 'admin.weather.climateDesc': 'Promedios de los últimos 85 años para fechas posteriores al pronóstico de 16 días', - 'admin.weather.requests': '10.000 solicitudes / día', - 'admin.weather.requestsDesc': 'Gratis, sin necesidad de clave API', - 'admin.weather.locationHint': 'El tiempo se basa en el primer lugar con coordenadas de cada día. Si no hay ningún lugar asignado a un día, se usa como referencia cualquier lugar de la lista.', - - // GitHub - 'admin.tabs.github': 'GitHub', - 'admin.github.title': 'Historial de versiones', - 'admin.github.subtitle': 'Últimas novedades de {repo}', - 'admin.github.latest': 'Última', - 'admin.github.prerelease': 'Prelanzamiento', - 'admin.github.showDetails': 'Mostrar detalles', - 'admin.github.hideDetails': 'Ocultar detalles', - 'admin.github.loadMore': 'Cargar más', - 'admin.github.loading': 'Cargando...', - 'admin.github.error': 'No se pudieron cargar las versiones', - 'admin.github.by': 'por', - 'admin.update.available': 'Actualización disponible', - 'admin.update.text': 'NOMAD {version} está disponible. Estás usando {current}.', - 'admin.update.button': 'Ver en GitHub', - 'admin.update.install': 'Instalar actualización', - 'admin.update.confirmTitle': '¿Instalar actualización?', - 'admin.update.confirmText': 'NOMAD se actualizará de {current} a {version}. Después, el servidor se reiniciará automáticamente.', - 'admin.update.dataInfo': 'Todos tus datos (viajes, usuarios, claves API, subidas, Vacay, Atlas, presupuestos) se conservarán.', - 'admin.update.warning': 'La app estará brevemente no disponible durante el reinicio.', - 'admin.update.confirm': 'Actualizar ahora', - 'admin.update.installing': 'Actualizando…', - 'admin.update.success': '¡Actualización instalada! El servidor se está reiniciando…', - 'admin.update.failed': 'La actualización falló', - 'admin.update.backupHint': 'Recomendamos crear una copia de seguridad antes de actualizar.', - 'admin.update.backupLink': 'Ir a Copia de seguridad', - 'admin.update.howTo': 'Cómo actualizar', - 'admin.update.dockerText': 'Tu instancia de NOMAD se ejecuta en Docker. Para actualizar a {version}, ejecuta los siguientes comandos en tu servidor:', - 'admin.update.reloadHint': 'Recarga la página en unos segundos.', - - // Vacay addon - 'vacay.subtitle': 'Planifica y gestiona días de vacaciones', - 'vacay.settings': 'Ajustes', - 'vacay.year': 'Año', - 'vacay.addYear': 'Añadir año', - 'vacay.removeYear': 'Eliminar año', - 'vacay.removeYearConfirm': '¿Eliminar {year}?', - 'vacay.removeYearHint': 'Todas las vacaciones y festivos de empresa de este año se borrarán permanentemente.', - 'vacay.remove': 'Eliminar', - 'vacay.persons': 'Personas', - 'vacay.noPersons': 'No se han añadido personas', - 'vacay.addPerson': 'Añadir persona', - 'vacay.editPerson': 'Editar persona', - 'vacay.removePerson': 'Eliminar persona', - 'vacay.removePersonConfirm': '¿Eliminar a {name}?', - 'vacay.removePersonHint': 'Todas las vacaciones de esta persona se borrarán permanentemente.', - 'vacay.personName': 'Nombre', - 'vacay.personNamePlaceholder': 'Introduce un nombre', - 'vacay.color': 'Color', - 'vacay.add': 'Añadir', - 'vacay.legend': 'Leyenda', - 'vacay.publicHoliday': 'Festivo', - 'vacay.companyHoliday': 'Festivo de empresa', - 'vacay.weekend': 'Fin de semana', - 'vacay.modeVacation': 'Vacaciones', - 'vacay.modeCompany': 'Festivo de empresa', - 'vacay.entitlement': 'Derecho', - 'vacay.entitlementDays': 'Días', - 'vacay.used': 'Usados', - 'vacay.remaining': 'Restantes', - 'vacay.carriedOver': 'de {year}', - 'vacay.blockWeekends': 'Bloquear fines de semana', - 'vacay.blockWeekendsHint': 'Impide marcar vacaciones en sábados y domingos', - 'vacay.publicHolidays': 'Festivos', - 'vacay.publicHolidaysHint': 'Marcar festivos en el calendario', - 'vacay.selectCountry': 'Seleccionar país', - 'vacay.selectRegion': 'Seleccionar región (opcional)', - 'vacay.companyHolidays': 'Festivos de empresa', - 'vacay.companyHolidaysHint': 'Permitir marcar días festivos comunes de la empresa', - 'vacay.companyHolidaysNoDeduct': 'Los festivos de empresa no descuentan días de vacaciones.', - 'vacay.carryOver': 'Arrastrar saldo', - 'vacay.carryOverHint': 'Trasladar automáticamente los días restantes al año siguiente', - 'vacay.sharing': 'Compartir', - 'vacay.sharingHint': 'Comparte tu calendario de vacaciones con otros usuarios de NOMAD', - 'vacay.owner': 'Propietario', - 'vacay.shareEmailPlaceholder': 'Correo electrónico del usuario de NOMAD', - 'vacay.shareSuccess': 'Plan compartido correctamente', - 'vacay.shareError': 'No se pudo compartir el plan', - 'vacay.dissolve': 'Deshacer fusión', - 'vacay.dissolveHint': 'Separar de nuevo los calendarios. Tus entradas se conservarán.', - 'vacay.dissolveAction': 'Disolver', - 'vacay.dissolved': 'Calendario separado', - 'vacay.fusedWith': 'Fusionado con', - 'vacay.you': 'tú', - 'vacay.noData': 'Sin datos', - 'vacay.changeColor': 'Cambiar color', - 'vacay.inviteUser': 'Invitar usuario', - 'vacay.inviteHint': 'Invita a otro usuario de NOMAD a compartir un calendario combinado de vacaciones.', - 'vacay.selectUser': 'Seleccionar usuario', - 'vacay.sendInvite': 'Enviar invitación', - 'vacay.inviteSent': 'Invitación enviada', - 'vacay.inviteError': 'No se pudo enviar la invitación', - 'vacay.pending': 'pendiente', - 'vacay.noUsersAvailable': 'No hay usuarios disponibles', - 'vacay.accept': 'Aceptar', - 'vacay.decline': 'Rechazar', - 'vacay.acceptFusion': 'Aceptar y fusionar', - 'vacay.inviteTitle': 'Solicitud de fusión', - 'vacay.inviteWantsToFuse': 'quiere compartir un calendario de vacaciones contigo.', - 'vacay.fuseInfo1': 'Ambos veréis todas las entradas de vacaciones en un único calendario compartido.', - 'vacay.fuseInfo2': 'Ambas partes pueden crear y editar entradas mutuamente.', - 'vacay.fuseInfo3': 'Ambas partes pueden borrar entradas y cambiar el número de días de vacaciones disponibles.', - 'vacay.fuseInfo4': 'Ajustes como festivos y festivos de empresa se comparten.', - 'vacay.fuseInfo5': 'La fusión puede disolverse en cualquier momento por cualquiera de las partes. Tus entradas se conservarán.', - - // Atlas addon - 'atlas.subtitle': 'Tu huella viajera por el mundo', - 'atlas.countries': 'Países', - 'atlas.trips': 'Viajes', - 'atlas.places': 'Lugares', - 'atlas.days': 'Días', - 'atlas.visitedCountries': 'Países visitados', - 'atlas.cities': 'Ciudades', - 'atlas.noData': 'Aún no hay datos de viaje', - 'atlas.noDataHint': 'Crea un viaje y añade lugares para ver tu mapa del mundo', - 'atlas.lastTrip': 'Último viaje', - 'atlas.nextTrip': 'Próximo viaje', - 'atlas.daysLeft': 'días restantes', - 'atlas.streak': 'Racha', - 'atlas.year': 'año', - 'atlas.years': 'años', - 'atlas.yearInRow': 'año seguido', - 'atlas.yearsInRow': 'años seguidos', - 'atlas.tripIn': 'viaje en', - 'atlas.tripsIn': 'viajes en', - 'atlas.since': 'desde', - 'atlas.europe': 'Europa', - 'atlas.asia': 'Asia', - 'atlas.northAmerica': 'América del Norte', - 'atlas.southAmerica': 'América del Sur', - 'atlas.africa': 'África', - 'atlas.oceania': 'Oceanía', - 'atlas.other': 'Otros', - 'atlas.firstVisit': 'Primer viaje', - 'atlas.lastVisitLabel': 'Último viaje', - 'atlas.tripSingular': 'Viaje', - 'atlas.tripPlural': 'Viajes', - 'atlas.placeVisited': 'Lugar visitado', - 'atlas.placesVisited': 'Lugares visitados', - - // Trip Planner - 'trip.tabs.plan': 'Plan', - 'trip.tabs.reservations': 'Reservas', - 'trip.tabs.reservationsShort': 'Reservas', - 'trip.tabs.packing': 'Lista de equipaje', - 'trip.tabs.packingShort': 'Equipaje', - 'trip.tabs.budget': 'Presupuesto', - 'trip.tabs.memories': 'Recuerdos', - 'trip.tabs.files': 'Archivos', - 'trip.loading': 'Cargando viaje...', - 'trip.mobilePlan': 'Plan', - 'trip.mobilePlaces': 'Lugares', - 'trip.toast.placeUpdated': 'Lugar actualizado', - 'trip.toast.placeAdded': 'Lugar añadido', - 'trip.toast.placeDeleted': 'Lugar eliminado', - 'trip.toast.selectDay': 'Selecciona primero un día', - 'trip.toast.assignedToDay': 'Lugar asignado al día', - 'trip.toast.reorderError': 'No se pudo reordenar', - 'trip.toast.reservationUpdated': 'Reserva actualizada', - 'trip.toast.reservationAdded': 'Reserva añadida', - 'trip.toast.deleted': 'Eliminado', - 'trip.confirm.deletePlace': '¿Seguro que quieres eliminar este lugar?', - - // Day Plan Sidebar - 'dayplan.emptyDay': 'No hay lugares planificados para este día', - 'dayplan.addNote': 'Añadir nota', - 'dayplan.editNote': 'Editar nota', - 'dayplan.noteAdd': 'Añadir nota', - 'dayplan.noteEdit': 'Editar nota', - 'dayplan.noteTitle': 'Nota', - 'dayplan.noteSubtitle': 'Nota diaria', - 'dayplan.totalCost': 'Coste total', - 'dayplan.days': 'Días', - 'dayplan.dayN': 'Día {n}', - 'dayplan.calculating': 'Calculando...', - 'dayplan.route': 'Ruta', - 'dayplan.optimize': 'Optimizar', - 'dayplan.optimized': 'Ruta optimizada', - 'dayplan.routeError': 'No se pudo calcular la ruta', - 'dayplan.toast.needTwoPlaces': 'Se necesitan al menos dos lugares para optimizar la ruta', - 'dayplan.toast.routeOptimized': 'Ruta optimizada', - 'dayplan.toast.noGeoPlaces': 'No se encontraron lugares con coordenadas para calcular la ruta', - 'dayplan.confirmed': 'Confirmado', - 'dayplan.pendingRes': 'Pendiente', - 'dayplan.pdf': 'PDF', - 'dayplan.pdfTooltip': 'Exportar plan diario como PDF', - 'dayplan.pdfError': 'No se pudo exportar el PDF', - - // Places Sidebar - 'places.addPlace': 'Añadir lugar/actividad', - 'places.assignToDay': '¿A qué día añadirlo?', - 'places.all': 'Todo', - 'places.unplanned': 'Sin planificar', - 'places.search': 'Buscar lugares...', - 'places.allCategories': 'Todas las categorías', - 'places.count': '{count} lugares', - 'places.countSingular': '1 lugar', - 'places.allPlanned': 'Todos los lugares están planificados', - 'places.noneFound': 'No se encontraron lugares', - 'places.editPlace': 'Editar lugar', - 'places.formName': 'Nombre', - 'places.formNamePlaceholder': 'p. ej. Torre Eiffel', - 'places.formDescription': 'Descripción', - 'places.formDescriptionPlaceholder': 'Descripción breve...', - 'places.formAddress': 'Dirección', - 'places.formAddressPlaceholder': 'Calle, ciudad, país', - 'places.formLat': 'Latitud (p. ej. 48.8566)', - 'places.formLng': 'Longitud (p. ej. 2.3522)', - 'places.formCategory': 'Categoría', - 'places.noCategory': 'Sin categoría', - 'places.categoryNamePlaceholder': 'Nombre de la categoría', - 'places.formTime': 'Hora', - 'places.startTime': 'Inicio', - 'places.endTime': 'Fin', - 'places.endTimeBeforeStart': 'La hora de fin es anterior a la de inicio', - 'places.timeCollision': 'Solapamiento horario con:', - 'places.formWebsite': 'Página web', - 'places.formNotesPlaceholder': 'Notas personales...', - 'places.formReservation': 'Reserva', - 'places.reservationNotesPlaceholder': 'Notas de reserva, número de confirmación...', - 'places.mapsSearchPlaceholder': 'Buscar lugares...', - 'places.mapsSearchError': 'La búsqueda de lugares falló.', - 'places.osmHint': 'Usando búsqueda con OpenStreetMap (sin fotos, horarios ni valoraciones). Añade una clave API de Google en Ajustes para obtener todos los detalles.', - 'places.osmActive': 'Búsqueda mediante OpenStreetMap (sin fotos, valoraciones ni horarios). Añade una clave API de Google en Ajustes para datos ampliados.', - 'places.categoryCreateError': 'No se pudo crear la categoría', - 'places.nameRequired': 'Introduce un nombre', - 'places.saveError': 'No se pudo guardar', - - // Place Inspector - 'inspector.opened': 'Abierto', - 'inspector.closed': 'Cerrado', - 'inspector.openingHours': 'Horario de apertura', - 'inspector.showHours': 'Mostrar horario', - 'inspector.files': 'Archivos', - 'inspector.filesCount': '{count} archivos', - 'inspector.removeFromDay': 'Quitar del día', - 'inspector.addToDay': 'Añadir al día', - 'inspector.confirmedRes': 'Reserva confirmada', - 'inspector.pendingRes': 'Reserva pendiente', - 'inspector.google': 'Abrir en Google Maps', - 'inspector.website': 'Abrir la web', - 'inspector.addRes': 'Reserva', - 'inspector.editRes': 'Editar reserva', - 'inspector.participants': 'Participantes', - - // Reservations - 'reservations.title': 'Reservas', - 'reservations.empty': 'Aún no hay reservas', - 'reservations.emptyHint': 'Añade reservas de vuelos, hoteles y más', - 'reservations.add': 'Añadir reserva', - 'reservations.addManual': 'Reserva manual', - 'reservations.placeHint': 'Consejo: es mejor crear las reservas directamente desde un lugar para vincularlas con el plan del día.', - 'reservations.confirmed': 'Confirmada', - 'reservations.pending': 'Pendiente', - 'reservations.summary': '{confirmed} confirmadas, {pending} pendientes', - 'reservations.fromPlan': 'Del plan', - 'reservations.showFiles': 'Mostrar archivos', - 'reservations.editTitle': 'Editar reserva', - 'reservations.status': 'Estado', - 'reservations.datetime': 'Fecha y hora', - 'reservations.startTime': 'Hora de inicio', - 'reservations.endTime': 'Hora de fin', - 'reservations.date': 'Fecha', - 'reservations.time': 'Hora', - 'reservations.timeAlt': 'Hora (alternativa, p. ej. 19:30)', - 'reservations.notes': 'Notas', - 'reservations.notesPlaceholder': 'Notas adicionales...', - 'reservations.type.flight': 'Vuelo', - 'reservations.type.hotel': 'Hotel', - 'reservations.type.restaurant': 'Restaurante', - 'reservations.type.train': 'Tren', - 'reservations.type.car': 'Coche de alquiler', - 'reservations.type.cruise': 'Crucero', - 'reservations.type.event': 'Evento', - 'reservations.type.tour': 'Tour', - 'reservations.type.other': 'Otro', - 'reservations.confirm.delete': '¿Seguro que quieres eliminar la reserva "{name}"?', - 'reservations.toast.updated': 'Reserva actualizada', - 'reservations.toast.removed': 'Reserva eliminada', - 'reservations.toast.fileUploaded': 'Archivo subido', - 'reservations.toast.uploadError': 'No se pudo subir', - 'reservations.newTitle': 'Nueva reserva', - 'reservations.bookingType': 'Tipo de reserva', - 'reservations.titleLabel': 'Título', - 'reservations.titlePlaceholder': 'p. ej. Lufthansa LH123, Hotel Adlon, ...', - 'reservations.locationAddress': 'Ubicación / dirección', - 'reservations.locationPlaceholder': 'Dirección, aeropuerto, hotel...', - 'reservations.confirmationCode': 'Código de reserva', - 'reservations.confirmationPlaceholder': 'p. ej. ABC12345', - 'reservations.day': 'Día', - 'reservations.noDay': 'Sin día', - 'reservations.place': 'Lugar', - 'reservations.noPlace': 'Sin lugar', - 'reservations.pendingSave': 'se guardará…', - 'reservations.uploading': 'Subiendo...', - 'reservations.attachFile': 'Adjuntar archivo', - 'reservations.toast.saveError': 'No se pudo guardar', - 'reservations.toast.updateError': 'No se pudo actualizar', - 'reservations.toast.deleteError': 'No se pudo eliminar', - 'reservations.confirm.remove': '¿Eliminar la reserva de "{name}"?', - 'reservations.linkAssignment': 'Vincular a una asignación del día', - 'reservations.pickAssignment': 'Selecciona una asignación de tu plan...', - 'reservations.noAssignment': 'Sin vínculo (independiente)', - - // Budget - 'budget.title': 'Presupuesto', - 'budget.emptyTitle': 'Aún no se ha creado ningún presupuesto', - 'budget.emptyText': 'Crea categorías y entradas para planificar el presupuesto de tu viaje', - 'budget.emptyPlaceholder': 'Introduce el nombre de la categoría...', - 'budget.createCategory': 'Crear categoría', - 'budget.category': 'Categoría', - 'budget.categoryName': 'Nombre de la categoría', - 'budget.table.name': 'Nombre', - 'budget.table.total': 'Total', - 'budget.table.persons': 'Personas', - 'budget.table.days': 'Días', - 'budget.table.perPerson': 'Por persona', - 'budget.table.perDay': 'Por día', - 'budget.table.perPersonDay': 'Por pers. / día', - 'budget.table.note': 'Nota', - 'budget.newEntry': 'Nueva entrada', - 'budget.defaultEntry': 'Nueva entrada', - 'budget.defaultCategory': 'Nueva categoría', - 'budget.total': 'Total', - 'budget.totalBudget': 'Presupuesto total', - 'budget.byCategory': 'Por categoría', - 'budget.editTooltip': 'Haz clic para editar', - 'budget.confirm.deleteCategory': '¿Seguro que quieres eliminar la categoría "{name}" con {count} entradas?', - 'budget.deleteCategory': 'Eliminar categoría', - 'budget.perPerson': 'Por persona', - 'budget.paid': 'Pagado', - 'budget.open': 'Abrir', - 'budget.noMembers': 'No hay miembros asignados', - - // Files - 'files.title': 'Archivos', - 'files.count': '{count} archivos', - 'files.countSingular': '1 archivo', - 'files.uploaded': '{count} archivos subidos', - 'files.uploadError': 'La subida falló', - 'files.dropzone': 'Arrastra aquí los archivos', - 'files.dropzoneHint': 'o haz clic para explorar', - 'files.allowedTypes': 'Imágenes, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Máx. 50 MB', - 'files.uploading': 'Subiendo...', - 'files.filterAll': 'Todo', - 'files.filterPdf': 'PDF', - 'files.filterImages': 'Imágenes', - 'files.filterDocs': 'Documentos', - 'files.filterCollab': 'Notas de colaboración', - 'files.sourceCollab': 'Desde notas de colaboración', - 'files.empty': 'Aún no hay archivos', - 'files.emptyHint': 'Sube archivos para adjuntarlos a tu viaje', - 'files.openTab': 'Abrir en una pestaña nueva', - 'files.confirm.delete': '¿Seguro que quieres eliminar este archivo?', - 'files.toast.deleted': 'Archivo eliminado', - 'files.toast.deleteError': 'No se pudo eliminar el archivo', - 'files.sourcePlan': 'Plan diario', - 'files.sourceBooking': 'Reserva', - 'files.attach': 'Adjuntar', - 'files.pasteHint': 'También puedes pegar imágenes desde el portapapeles (Ctrl+V)', - - // Packing - 'packing.title': 'Lista de equipaje', - 'packing.empty': 'La lista de equipaje está vacía', - 'packing.progress': '{packed} de {total} preparados ({percent}%)', - 'packing.clearChecked': 'Eliminar {count} marcados', - 'packing.clearCheckedShort': 'Eliminar {count}', - 'packing.suggestions': 'Sugerencias', - 'packing.suggestionsTitle': 'Añadir sugerencias', - 'packing.allSuggested': 'Todas las sugerencias añadidas', - 'packing.allPacked': '¡Todo preparado!', - 'packing.addPlaceholder': 'Añadir nuevo elemento...', - 'packing.categoryPlaceholder': 'Categoría...', - 'packing.filterAll': 'Todo', - 'packing.filterOpen': 'Pendientes', - 'packing.filterDone': 'Hecho', - 'packing.emptyTitle': 'La lista de equipaje está vacía', - 'packing.emptyHint': 'Añade elementos o usa las sugerencias', - 'packing.emptyFiltered': 'Ningún elemento coincide con este filtro', - 'packing.menuRename': 'Renombrar', - 'packing.menuCheckAll': 'Marcar todo', - 'packing.menuUncheckAll': 'Desmarcar todo', - 'packing.menuDeleteCat': 'Eliminar categoría', - 'packing.changeCategory': 'Cambiar categoría', - 'packing.confirm.clearChecked': '¿Seguro que quieres eliminar {count} elementos marcados?', - 'packing.confirm.deleteCat': '¿Seguro que quieres eliminar la categoría "{name}" con {count} elementos?', - 'packing.defaultCategory': 'Otros', - 'packing.toast.saveError': 'No se pudo guardar', - 'packing.toast.deleteError': 'No se pudo eliminar', - 'packing.toast.renameError': 'No se pudo renombrar', - 'packing.toast.addError': 'No se pudo añadir', - - // Packing suggestions - 'packing.suggestions.items': [ - { name: 'Pasaporte', category: 'Documentos' }, - { name: 'Documento de identidad', category: 'Documentos' }, - { name: 'Seguro de viaje', category: 'Documentos' }, - { name: 'Billetes de vuelo', category: 'Documentos' }, - { name: 'Tarjeta de crédito', category: 'Finanzas' }, - { name: 'Efectivo', category: 'Finanzas' }, - { name: 'Visado', category: 'Documentos' }, - { name: 'Camisetas', category: 'Ropa' }, - { name: 'Pantalones', category: 'Ropa' }, - { name: 'Ropa interior', category: 'Ropa' }, - { name: 'Calcetines', category: 'Ropa' }, - { name: 'Chaqueta', category: 'Ropa' }, - { name: 'Pijama', category: 'Ropa' }, - { name: 'Ropa de baño', category: 'Ropa' }, - { name: 'Impermeable', category: 'Ropa' }, - { name: 'Zapatos cómodos', category: 'Ropa' }, - { name: 'Cepillo de dientes', category: 'Aseo' }, - { name: 'Pasta de dientes', category: 'Aseo' }, - { name: 'Champú', category: 'Aseo' }, - { name: 'Desodorante', category: 'Aseo' }, - { name: 'Protector solar', category: 'Aseo' }, - { name: 'Maquinilla de afeitar', category: 'Aseo' }, - { name: 'Cargador', category: 'Electrónica' }, - { name: 'Batería externa', category: 'Electrónica' }, - { name: 'Auriculares', category: 'Electrónica' }, - { name: 'Adaptador de viaje', category: 'Electrónica' }, - { name: 'Cámara', category: 'Electrónica' }, - { name: 'Analgésicos', category: 'Salud' }, - { name: 'Tiritas', category: 'Salud' }, - { name: 'Desinfectante', category: 'Salud' }, - ], - - // Members / Sharing - 'members.shareTrip': 'Compartir viaje', - 'members.inviteUser': 'Invitar usuario', - 'members.selectUser': 'Seleccionar usuario…', - 'members.invite': 'Invitar', - 'members.allHaveAccess': 'Todos los usuarios ya tienen acceso.', - 'members.access': 'Acceso', - 'members.person': 'persona', - 'members.persons': 'personas', - 'members.you': 'tú', - 'members.owner': 'Propietario', - 'members.leaveTrip': 'Abandonar viaje', - 'members.removeAccess': 'Quitar acceso', - 'members.confirmLeave': '¿Abandonar el viaje? Perderás el acceso.', - 'members.confirmRemove': '¿Quitar el acceso de este usuario?', - 'members.loadError': 'No se pudieron cargar los miembros', - 'members.added': 'añadido', - 'members.addError': 'No se pudo añadir', - 'members.removed': 'Miembro eliminado', - 'members.removeError': 'No se pudo eliminar', - - // Categories (Admin) - 'categories.title': 'Categorías', - 'categories.subtitle': 'Gestiona categorías para lugares', - 'categories.new': 'Nueva categoría', - 'categories.empty': 'Aún no hay categorías', - 'categories.namePlaceholder': 'Nombre de la categoría', - 'categories.icon': 'Icono', - 'categories.color': 'Color', - 'categories.customColor': 'Elegir color personalizado', - 'categories.preview': 'Vista previa', - 'categories.defaultName': 'Categoría', - 'categories.update': 'Actualizar', - 'categories.create': 'Crear', - 'categories.confirm.delete': '¿Eliminar la categoría? Los lugares de esta categoría no se eliminarán.', - 'categories.toast.loadError': 'No se pudieron cargar las categorías', - 'categories.toast.nameRequired': 'Introduce un nombre', - 'categories.toast.updated': 'Categoría actualizada', - 'categories.toast.created': 'Categoría creada', - 'categories.toast.saveError': 'No se pudo guardar', - 'categories.toast.deleted': 'Categoría eliminada', - 'categories.toast.deleteError': 'No se pudo eliminar', - - // Backup (Admin) - 'backup.title': 'Copia de seguridad de datos', - 'backup.subtitle': 'Base de datos y todos los archivos subidos', - 'backup.refresh': 'Actualizar', - 'backup.upload': 'Subir copia de seguridad', - 'backup.uploading': 'Subiendo…', - 'backup.create': 'Crear copia', - 'backup.creating': 'Creando…', - 'backup.empty': 'Aún no hay copias', - 'backup.createFirst': 'Crear la primera copia', - 'backup.download': 'Descargar', - 'backup.restore': 'Restaurar', - 'backup.confirm.restore': '¿Restaurar la copia "{name}"?\n\nTodos los datos actuales serán reemplazados por la copia.', - 'backup.confirm.uploadRestore': '¿Subir y restaurar el archivo de copia "{name}"?\n\nTodos los datos actuales se sobrescribirán.', - 'backup.confirm.delete': '¿Eliminar la copia "{name}"?', - 'backup.toast.loadError': 'No se pudieron cargar las copias', - 'backup.toast.created': 'Copia de seguridad creada correctamente', - 'backup.toast.createError': 'No se pudo crear la copia', - 'backup.toast.restored': 'Copia restaurada. La página se recargará…', - 'backup.toast.restoreError': 'No se pudo restaurar', - 'backup.toast.uploadError': 'No se pudo subir', - 'backup.toast.deleted': 'Copia eliminada', - 'backup.toast.deleteError': 'No se pudo eliminar', - 'backup.toast.downloadError': 'La descarga falló', - 'backup.toast.settingsSaved': 'Ajustes de copia automática guardados', - 'backup.toast.settingsError': 'No se pudieron guardar los ajustes', - 'backup.auto.title': 'Copia automática', - 'backup.auto.subtitle': 'Copia de seguridad automática según una programación', - 'backup.auto.enable': 'Activar copia automática', - 'backup.auto.enableHint': 'Se crearán copias automáticamente según la frecuencia elegida', - 'backup.auto.interval': 'Intervalo', - 'backup.auto.keepLabel': 'Eliminar copias antiguas después de', - 'backup.interval.hourly': 'Cada hora', - 'backup.interval.daily': 'Diaria', - 'backup.interval.weekly': 'Semanal', - 'backup.interval.monthly': 'Mensual', - 'backup.keep.1day': '1 día', - 'backup.keep.3days': '3 días', - 'backup.keep.7days': '7 días', - 'backup.keep.14days': '14 días', - 'backup.keep.30days': '30 días', - 'backup.keep.forever': 'Conservar para siempre', - - // Photos - 'photos.allDays': 'Todos los días', - 'photos.title': 'Recuerdos', - 'photos.noPhotos': 'Aún no hay fotos', - 'photos.uploadHint': 'Sube y organiza las fotos compartidas de este viaje', - 'photos.clickToSelect': 'o haz clic para seleccionar', - 'photos.dropHere': 'Suelta aquí las fotos...', - 'photos.dropTitle': 'Suelta aquí las fotos', - 'photos.fileHint': 'JPG, PNG, GIF, WebP · máx. 10 MB · hasta 30 fotos', - 'photos.selectedCount': '{count} foto(s) seleccionada(s)', - 'photos.sharedAlbum': '{count} recuerdos en este álbum compartido', - 'photos.sharedAlbumFor': '{count} recuerdos en {trip}', - 'photos.allPlaces': 'Todos los lugares', - 'photos.view.grid': 'Cuadrícula', - 'photos.view.day': 'Por día', - 'photos.view.place': 'Por lugar', - 'photos.stats.total': 'Fotos', - 'photos.stats.days': 'Días', - 'photos.stats.places': 'Lugares', - 'photos.stats.latest': 'Última subida', - 'photos.sectionCount': '{count} foto(s)', - 'photos.ungrouped': 'Sin clasificar', - 'photos.featured': 'Recuerdo destacado', - 'photos.coverFallback': 'Portada del álbum compartido', - 'photos.coverHint': 'Una imagen destacada para este álbum de viaje', - 'photos.mapTitle': 'Mapa de recuerdos', - 'photos.mapHint': 'Explora los lugares vinculados en el mismo mapa que usamos en el plan', - 'photos.mapEmpty': 'Vincula tus fotos a lugares para verlas ubicadas en el mapa.', - 'photos.linkDay': 'Vincular día', - 'photos.noDay': 'Sin día', - 'photos.linkPlace': 'Vincular lugar', - 'photos.noPlace': 'Sin lugar', - 'photos.captionLabel': 'Pie de foto (para todas)', - 'photos.captionPlaceholder': 'Pie de foto opcional...', - 'photos.addCaption': 'Añadir un pie de foto...', - 'photos.uploadN': 'Subida de {n} foto(s)', - 'admin.addons.catalog.memories.name': 'Recuerdos', - 'admin.addons.catalog.memories.description': 'Álbumes de fotos compartidos para cada viaje', - 'admin.addons.catalog.packing.name': 'Equipaje', - 'admin.addons.catalog.packing.description': 'Prepara tu equipaje con listas de comprobación para cada viaje', - 'admin.addons.catalog.budget.name': 'Presupuesto', - 'admin.addons.catalog.budget.description': 'Controla los gastos y planifica el presupuesto del viaje', - 'admin.addons.catalog.documents.name': 'Documentos', - 'admin.addons.catalog.documents.description': 'Guarda y gestiona la documentación del viaje', - 'admin.addons.catalog.vacay.name': 'Vacaciones', - 'admin.addons.catalog.vacay.description': 'Planificador personal de vacaciones con vista de calendario', - 'admin.addons.catalog.atlas.name': 'Atlas', - 'admin.addons.catalog.atlas.description': 'Mapa del mundo con los países visitados y estadísticas de viaje', - 'admin.addons.catalog.collab.name': 'Colaboración', - 'admin.addons.catalog.collab.description': 'Notas, encuestas y chat en tiempo real para organizar el viaje', - - // Backup restore modal - 'backup.restoreConfirmTitle': '¿Restaurar copia?', - 'backup.restoreWarning': 'Todos los datos actuales (viajes, lugares, usuarios, subidas) serán reemplazados permanentemente por la copia. Esta acción no se puede deshacer.', - 'backup.restoreTip': 'Consejo: crea una copia del estado actual antes de restaurar.', - 'backup.restoreConfirm': 'Sí, restaurar', - - // PDF - 'pdf.travelPlan': 'Plan de viaje', - 'pdf.planned': 'Planificado', - 'pdf.costLabel': 'Coste EUR', - 'pdf.preview': 'Vista previa PDF', - 'pdf.saveAsPdf': 'Guardar como PDF', - - // Planner - 'planner.places': 'Lugares', - 'planner.bookings': 'Reservas', - 'planner.packingList': 'Lista de equipaje', - 'planner.documents': 'Documentos', - 'planner.dayPlan': 'Plan por días', - 'planner.reservations': 'Reservas', - 'planner.minTwoPlaces': 'Se necesitan al menos 2 lugares con coordenadas', - 'planner.noGeoPlaces': 'No hay lugares con coordenadas disponibles', - 'planner.routeCalculated': 'Ruta calculada', - 'planner.routeCalcFailed': 'No se pudo calcular la ruta', - 'planner.routeError': 'Error al calcular la ruta', - 'planner.routeOptimized': 'Ruta optimizada', - 'planner.reservationUpdated': 'Reserva actualizada', - 'planner.reservationAdded': 'Reserva añadida', - 'planner.confirmDeleteReservation': '¿Eliminar reserva?', - 'planner.reservationDeleted': 'Reserva eliminada', - 'planner.days': 'Días', - 'planner.allPlaces': 'Todos los lugares', - 'planner.totalPlaces': '{n} lugares en total', - 'planner.noDaysPlanned': 'Aún no hay días planificados', - 'planner.editTrip': 'Editar viaje →', - 'planner.placeOne': '1 lugar', - 'planner.placeN': '{n} lugares', - 'planner.addNote': 'Añadir nota', - 'planner.noEntries': 'No hay entradas para este día', - 'planner.addPlace': 'Añadir lugar/actividad', - 'planner.addPlaceShort': '+ Añadir lugar/actividad', - 'planner.resPending': 'Reserva pendiente · ', - 'planner.resConfirmed': 'Reserva confirmada · ', - 'planner.notePlaceholder': 'Nota…', - 'planner.noteTimePlaceholder': 'Hora (opcional)', - 'planner.noteExamplePlaceholder': 'p. ej. S3 a las 14:30 desde la estación central, ferry desde el muelle 7, pausa para comer…', - 'planner.totalCost': 'Coste total', - 'planner.searchPlaces': 'Buscar lugares…', - 'planner.allCategories': 'Todas las categorías', - 'planner.noPlacesFound': 'No se encontraron lugares', - 'planner.addFirstPlace': 'Añadir el primer lugar', - 'planner.noReservations': 'Sin reservas', - 'planner.addFirstReservation': 'Añadir la primera reserva', - 'planner.new': 'Nuevo', - 'planner.addToDay': '+ Día', - 'planner.calculating': 'Calculando…', - 'planner.route': 'Ruta', - 'planner.optimize': 'Optimizar', - 'planner.openGoogleMaps': 'Abrir en Google Maps', - 'planner.selectDayHint': 'Selecciona un día de la lista izquierda para ver su plan', - 'planner.noPlacesForDay': 'Aún no hay lugares para este día', - 'planner.addPlacesLink': 'Añadir lugares →', - 'planner.minTotal': 'min en total', - 'planner.noReservation': 'Sin reserva', - 'planner.removeFromDay': 'Quitar del día', - 'planner.addToThisDay': 'Añadir al día', - 'planner.overview': 'Vista general', - 'planner.noDays': 'No hay días todavía', - 'planner.editTripToAddDays': 'Edita el viaje para añadir días', - 'planner.dayCount': '{n} días', - 'planner.clickToUnlock': 'Haz clic para desbloquear', - 'planner.keepPosition': 'Mantener posición durante la optimización de ruta', - 'planner.dayDetails': 'Detalles del día', - 'planner.dayN': 'Día {n}', - 'planner.notes': 'Notas', - 'planner.addDayNote': 'Añadir notas para este día...', - - // Dashboard Stats - 'stats.countries': 'Países', - 'stats.cities': 'Ciudades', - 'stats.trips': 'Viajes', - 'stats.places': 'Lugares', - 'stats.worldProgress': 'Progreso mundial', - 'stats.visited': 'visitados', - 'stats.remaining': 'restantes', - 'stats.visitedCountries': 'Países visitados', - - // Day Detail Panel - 'day.precipProb': 'Probabilidad de lluvia', - 'day.precipitation': 'Precipitación', - 'day.wind': 'Viento', - 'day.sunrise': 'Amanecer', - 'day.sunset': 'Atardecer', - 'day.hourlyForecast': 'Pronóstico por horas', - 'day.climateHint': 'Promedios históricos: el pronóstico real está disponible dentro de los 16 días previos a la fecha.', - 'day.noWeather': 'No hay datos meteorológicos disponibles. Añade un lugar con coordenadas.', - 'day.overview': 'Resumen diario', - 'day.accommodation': 'Alojamiento', - 'day.addAccommodation': 'Añadir alojamiento', - 'day.hotelDayRange': 'Aplicar a los días', - 'day.noPlacesForHotel': 'Añade primero lugares al viaje', - 'day.allDays': 'Todos', - 'day.checkIn': 'Check-in', - 'day.checkOut': 'Check-out', - 'day.confirmation': 'Confirmación', - 'day.editAccommodation': 'Editar alojamiento', - 'day.reservations': 'Reservas', - - // Collab Addon - 'collab.tabs.chat': 'Mensajes', - 'collab.tabs.notes': 'Notas', - 'collab.tabs.polls': 'Encuestas', - 'collab.whatsNext.title': 'Qué viene ahora', - 'collab.whatsNext.today': 'Hoy', - 'collab.whatsNext.tomorrow': 'Mañana', - 'collab.whatsNext.empty': 'No hay actividades próximas', - 'collab.whatsNext.until': 'hasta', - 'collab.whatsNext.emptyHint': 'Las actividades con hora aparecerán aquí', - 'collab.chat.send': 'Enviar', - 'collab.chat.placeholder': 'Escribe un mensaje...', - 'collab.chat.empty': 'Empieza la conversación', - 'collab.chat.emptyHint': 'Los mensajes se comparten con todos los miembros del viaje', - 'collab.chat.emptyDesc': 'Comparte ideas, planes y novedades con tu grupo de viaje', - 'collab.chat.today': 'Hoy', - 'collab.chat.yesterday': 'Ayer', - 'collab.chat.deletedMessage': 'eliminó un mensaje', - 'collab.chat.loadMore': 'Cargar mensajes anteriores', - 'collab.chat.justNow': 'justo ahora', - 'collab.chat.minutesAgo': 'hace {n} min', - 'collab.chat.hoursAgo': 'hace {n} h', - 'collab.notes.title': 'Notas', - 'collab.notes.new': 'Nueva nota', - 'collab.notes.empty': 'Aún no hay notas', - 'collab.notes.emptyHint': 'Empieza a capturar ideas y planes', - 'collab.notes.all': 'Todas', - 'collab.notes.titlePlaceholder': 'Título de la nota', - 'collab.notes.contentPlaceholder': 'Escribe algo...', - 'collab.notes.categoryPlaceholder': 'Categoría', - 'collab.notes.newCategory': 'Nueva categoría...', - 'collab.notes.category': 'Categoría', - 'collab.notes.noCategory': 'Sin categoría', - 'collab.notes.color': 'Color', - 'collab.notes.save': 'Guardar', - 'collab.notes.cancel': 'Cancelar', - 'collab.notes.edit': 'Editar', - 'collab.notes.delete': 'Eliminar', - 'collab.notes.pin': 'Fijar', - 'collab.notes.unpin': 'Desfijar', - 'collab.notes.daysAgo': 'hace {n} d', - 'collab.notes.categorySettings': 'Gestionar categorías', - 'collab.notes.create': 'Crear', - 'collab.notes.website': 'Sitio web', - 'collab.notes.websitePlaceholder': 'https://...', - 'collab.notes.attachFiles': 'Adjuntar archivos', - 'collab.notes.noCategoriesYet': 'Aún no hay categorías', - 'collab.notes.emptyDesc': 'Crea una nota para empezar', - 'collab.polls.title': 'Encuestas', - 'collab.polls.new': 'Nueva encuesta', - 'collab.polls.empty': 'Aún no hay encuestas', - 'collab.polls.emptyHint': 'Pregunta al grupo y votad juntos', - 'collab.polls.question': 'Pregunta', - 'collab.polls.questionPlaceholder': '¿Qué deberíamos hacer?', - 'collab.polls.addOption': '+ Añadir opción', - 'collab.polls.optionPlaceholder': 'Opción {n}', - 'collab.polls.create': 'Crear encuesta', - 'collab.polls.close': 'Cerrar', - 'collab.polls.closed': 'Cerrada', - 'collab.polls.votes': '{n} votos', - 'collab.polls.vote': '{n} voto', - 'collab.polls.multipleChoice': 'Selección múltiple', - 'collab.polls.multiChoice': 'Selección múltiple', - 'collab.polls.deadline': 'Fecha límite', - 'collab.polls.option': 'Opción', - 'collab.polls.options': 'Opciones', - 'collab.polls.delete': 'Eliminar', - 'collab.polls.closedSection': 'Cerradas', -} - -export default es diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 9e11b4e..132ac0b 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -1 +1,1113 @@ -export { default } from './es.js' +const es: Record = { + // Common + 'common.save': 'Guardar', + 'common.cancel': 'Cancelar', + 'common.delete': 'Eliminar', + 'common.edit': 'Editar', + 'common.add': 'Añadir', + 'common.loading': 'Cargando...', + 'common.error': 'Error', + 'common.back': 'Atrás', + 'common.all': 'Todo', + 'common.close': 'Cerrar', + 'common.open': 'Abrir', + 'common.upload': 'Subir', + 'common.search': 'Buscar', + 'common.confirm': 'Confirmar', + 'common.ok': 'Aceptar', + 'common.yes': 'Sí', + 'common.no': 'No', + 'common.or': 'o', + 'common.none': 'Ninguno', + 'common.date': 'Fecha', + 'common.rename': 'Renombrar', + 'common.name': 'Nombre', + 'common.email': 'Correo', + 'common.password': 'Contraseña', + 'common.saving': 'Guardando...', + 'common.update': 'Actualizar', + 'common.change': 'Cambiar', + 'common.uploading': 'Subiendo…', + 'common.backToPlanning': 'Volver a la planificación', + 'common.reset': 'Restablecer', + + // Navbar + 'nav.trip': 'Viaje', + 'nav.share': 'Compartir', + 'nav.settings': 'Ajustes', + 'nav.admin': 'Administración', + 'nav.logout': 'Cerrar sesión', + 'nav.lightMode': 'Modo claro', + 'nav.darkMode': 'Modo oscuro', + 'nav.autoMode': 'Modo automático', + 'nav.administrator': 'Administrador', + 'nav.myTrips': 'Mis viajes', + + // Dashboard + 'dashboard.title': 'Mis viajes', + 'dashboard.subtitle.loading': 'Cargando viajes...', + 'dashboard.subtitle.trips': '{count} viajes ({archived} archivados)', + 'dashboard.subtitle.empty': 'Empieza tu primer viaje', + 'dashboard.subtitle.activeOne': '{count} viaje activo', + 'dashboard.subtitle.activeMany': '{count} viajes activos', + 'dashboard.subtitle.archivedSuffix': ' · {count} archivados', + 'dashboard.newTrip': 'Nuevo viaje', + 'dashboard.currency': 'Divisa', + 'dashboard.timezone': 'Zonas horarias', + 'dashboard.localTime': 'Hora local', + 'dashboard.emptyTitle': 'Aún no hay viajes', + 'dashboard.emptyText': 'Crea tu primer viaje y empieza a planificar', + 'dashboard.emptyButton': 'Crear primer viaje', + 'dashboard.nextTrip': 'Próximo viaje', + 'dashboard.shared': 'Compartido', + 'dashboard.sharedBy': 'Compartido por {name}', + 'dashboard.days': 'Días', + 'dashboard.places': 'Lugares', + 'dashboard.archive': 'Archivar', + 'dashboard.restore': 'Restaurar', + 'dashboard.archived': 'Archivado', + 'dashboard.status.ongoing': 'En curso', + 'dashboard.status.today': 'Hoy', + 'dashboard.status.tomorrow': 'Mañana', + 'dashboard.status.past': 'Pasado', + 'dashboard.status.daysLeft': 'Quedan {count} días', + 'dashboard.toast.loadError': 'No se pudieron cargar los viajes', + 'dashboard.toast.created': '¡Viaje creado correctamente!', + 'dashboard.toast.createError': 'No se pudo crear el viaje', + 'dashboard.toast.updated': '¡Viaje actualizado!', + 'dashboard.toast.updateError': 'No se pudo actualizar el viaje', + 'dashboard.toast.deleted': 'Viaje eliminado', + 'dashboard.toast.deleteError': 'No se pudo eliminar el viaje', + 'dashboard.toast.archived': 'Viaje archivado', + 'dashboard.toast.archiveError': 'No se pudo archivar el viaje', + 'dashboard.toast.restored': 'Viaje restaurado', + 'dashboard.toast.restoreError': 'No se pudo restaurar el viaje', + 'dashboard.confirm.delete': '¿Eliminar el viaje "{title}"? Todos los lugares y planes se borrarán permanentemente.', + 'dashboard.editTrip': 'Editar viaje', + 'dashboard.createTrip': 'Crear nuevo viaje', + 'dashboard.tripTitle': 'Título', + 'dashboard.tripTitlePlaceholder': 'p. ej. Verano en Japón', + 'dashboard.tripDescription': 'Descripción', + 'dashboard.tripDescriptionPlaceholder': '¿De qué trata este viaje?', + 'dashboard.startDate': 'Fecha de inicio', + 'dashboard.endDate': 'Fecha de fin', + 'dashboard.noDateHint': 'Sin fecha definida: se crearán 7 días por defecto. Puedes cambiarlo cuando quieras.', + 'dashboard.coverImage': 'Imagen de portada', + 'dashboard.addCoverImage': 'Añadir imagen de portada', + 'dashboard.coverSaved': 'Imagen de portada guardada', + 'dashboard.coverUploadError': 'Error al subir la imagen', + 'dashboard.coverRemoveError': 'Error al eliminar la imagen', + 'dashboard.titleRequired': 'El título es obligatorio', + 'dashboard.endDateError': 'La fecha de fin debe ser posterior a la de inicio', + + // Settings + 'settings.title': 'Ajustes', + 'settings.subtitle': 'Configura tus ajustes personales', + 'settings.map': 'Mapa', + 'settings.mapTemplate': 'Plantilla del mapa', + 'settings.mapTemplatePlaceholder.select': 'Seleccionar plantilla...', + 'settings.mapDefaultHint': 'Déjalo vacío para OpenStreetMap (por defecto)', + 'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + 'settings.mapHint': 'Plantilla de URL para los mosaicos del mapa', + 'settings.latitude': 'Latitud', + 'settings.longitude': 'Longitud', + 'settings.saveMap': 'Guardar mapa', + 'settings.apiKeys': 'Claves API', + 'settings.mapsKey': 'Clave API de Google Maps', + 'settings.mapsKeyHint': 'Necesaria para buscar lugares. Consíguela en console.cloud.google.com', + 'settings.weatherKey': 'Clave API de OpenWeatherMap', + 'settings.weatherKeyHint': 'Para datos meteorológicos. Gratis en openweathermap.org/api', + 'settings.keyPlaceholder': 'Introduce la clave...', + 'settings.configured': 'Configurado', + 'settings.saveKeys': 'Guardar claves', + 'settings.display': 'Visualización', + 'settings.colorMode': 'Modo de color', + 'settings.light': 'Claro', + 'settings.dark': 'Oscuro', + 'settings.auto': 'Automático', + 'settings.language': 'Idioma', + 'settings.temperature': 'Unidad de temperatura', + 'settings.timeFormat': 'Formato de hora', + 'settings.routeCalculation': 'Cálculo de ruta', + 'settings.on': 'Activado', + 'settings.off': 'Desactivado', + 'settings.account': 'Cuenta', + 'settings.username': 'Usuario', + 'settings.email': 'Correo', + 'settings.role': 'Rol', + 'settings.roleAdmin': 'Administrador', + 'settings.oidcLinked': 'Vinculado con', + 'settings.changePassword': 'Cambiar contraseña', + 'settings.currentPassword': 'Contraseña actual', + 'settings.newPassword': 'Nueva contraseña', + 'settings.confirmPassword': 'Confirmar nueva contraseña', + 'settings.updatePassword': 'Actualizar contraseña', + 'settings.passwordRequired': 'Introduce la contraseña actual y la nueva', + 'settings.passwordTooShort': 'La contraseña debe tener al menos 8 caracteres', + 'settings.passwordMismatch': 'Las contraseñas no coinciden', + 'settings.passwordChanged': 'Contraseña cambiada correctamente', + 'settings.deleteAccount': 'Eliminar cuenta', + 'settings.deleteAccountTitle': '¿Eliminar tu cuenta?', + 'settings.deleteAccountWarning': 'Tu cuenta y todos tus viajes, lugares y archivos se eliminarán permanentemente. Esta acción no se puede deshacer.', + 'settings.deleteAccountConfirm': 'Eliminar permanentemente', + 'settings.deleteBlockedTitle': 'No es posible eliminarla', + 'settings.deleteBlockedMessage': 'Eres el único administrador. Asciende a otro usuario a administrador antes de eliminar tu cuenta.', + 'settings.roleUser': 'Usuario', + 'settings.saveProfile': 'Guardar perfil', + 'settings.toast.mapSaved': 'Ajustes del mapa guardados', + 'settings.toast.keysSaved': 'Claves API guardadas', + 'settings.toast.displaySaved': 'Ajustes de visualización guardados', + 'settings.toast.profileSaved': 'Perfil guardado', + 'settings.uploadAvatar': 'Subir foto de perfil', + 'settings.removeAvatar': 'Eliminar foto de perfil', + 'settings.avatarUploaded': 'Foto de perfil actualizada', + 'settings.avatarRemoved': 'Foto de perfil eliminada', + 'settings.avatarError': 'Falló la subida', + + // Login + 'login.error': 'Inicio de sesión fallido. Revisa tus credenciales.', + 'login.tagline': 'Tus viajes.\nTu plan.', + 'login.description': 'Planifica viajes en colaboración con mapas interactivos, presupuestos y sincronización en tiempo real.', + 'login.features.maps': 'Mapas interactivos', + 'login.features.mapsDesc': 'Google Places, rutas y agrupación', + 'login.features.realtime': 'Sincronización en tiempo real', + 'login.features.realtimeDesc': 'Planificad juntos mediante WebSocket', + 'login.features.budget': 'Control de presupuesto', + 'login.features.budgetDesc': 'Categorías, gráficos y costes por persona', + 'login.features.collab': 'Colaboración', + 'login.features.collabDesc': 'Multiusuario con viajes compartidos', + 'login.features.packing': 'Listas de equipaje', + 'login.features.packingDesc': 'Categorías, progreso y sugerencias', + 'login.features.bookings': 'Reservas', + 'login.features.bookingsDesc': 'Vuelos, hoteles, restaurantes y más', + 'login.features.files': 'Documentos', + 'login.features.filesDesc': 'Sube y gestiona documentos', + 'login.features.routes': 'Rutas inteligentes', + 'login.features.routesDesc': 'Optimización automática y exportación a Google Maps', + 'login.selfHosted': 'Autoalojado · Código abierto · Tus datos siguen siendo tuyos', + 'login.title': 'Iniciar sesión', + 'login.subtitle': 'Bienvenido de nuevo', + 'login.signingIn': 'Iniciando sesión…', + 'login.signIn': 'Entrar', + 'login.createAdmin': 'Crear cuenta de administrador', + 'login.createAdminHint': 'Configura la primera cuenta administradora de NOMAD.', + 'login.createAccount': 'Crear cuenta', + 'login.createAccountHint': 'Crea una cuenta nueva.', + 'login.creating': 'Creando…', + 'login.noAccount': '¿No tienes cuenta?', + 'login.hasAccount': '¿Ya tienes cuenta?', + 'login.register': 'Registrarse', + 'login.emailPlaceholder': 'tu@correo.com', + 'login.username': 'Usuario', + 'login.oidc.registrationDisabled': 'El registro está desactivado. Contacta con tu administrador.', + 'login.oidc.noEmail': 'No se recibió ningún correo del proveedor.', + 'login.oidc.tokenFailed': 'La autenticación falló.', + 'login.oidc.invalidState': 'Sesión no válida. Inténtalo de nuevo.', + 'login.demoFailed': 'Falló el acceso a la demo', + 'login.oidcSignIn': 'Entrar con {name}', + 'login.demoHint': 'Prueba la demo: no necesitas registrarte', + + // Register + 'register.passwordMismatch': 'Las contraseñas no coinciden', + 'register.passwordTooShort': 'La contraseña debe tener al menos 6 caracteres', + 'register.failed': 'Falló el registro', + 'register.getStarted': 'Empezar', + 'register.subtitle': 'Crea una cuenta y empieza a planificar tus viajes.', + 'register.feature1': 'Planes de viaje ilimitados', + 'register.feature2': 'Vista de mapa interactiva', + 'register.feature3': 'Gestiona lugares y categorías', + 'register.feature4': 'Haz seguimiento de las reservas', + 'register.feature5': 'Crea listas de equipaje', + 'register.feature6': 'Guarda fotos y archivos', + 'register.createAccount': 'Crear cuenta', + 'register.startPlanning': 'Empieza a planificar tu viaje', + 'register.minChars': 'Mín. 6 caracteres', + 'register.confirmPassword': 'Confirmar contraseña', + 'register.repeatPassword': 'Repetir contraseña', + 'register.registering': 'Registrando...', + 'register.register': 'Registrarse', + 'register.hasAccount': '¿Ya tienes cuenta?', + 'register.signIn': 'Iniciar sesión', + + // Admin + 'admin.title': 'Administración', + 'admin.subtitle': 'Gestión de usuarios y ajustes del sistema', + 'admin.tabs.users': 'Usuarios', + 'admin.tabs.categories': 'Categorías', + 'admin.tabs.backup': 'Copia de seguridad', + 'admin.stats.users': 'Usuarios', + 'admin.stats.trips': 'Viajes', + 'admin.stats.places': 'Lugares', + 'admin.stats.photos': 'Fotos', + 'admin.stats.files': 'Archivos', + 'admin.table.user': 'Usuario', + 'admin.table.email': 'Correo', + 'admin.table.role': 'Rol', + 'admin.table.created': 'Creado', + 'admin.table.lastLogin': 'Último acceso', + 'admin.table.actions': 'Acciones', + 'admin.you': '(Tú)', + 'admin.editUser': 'Editar usuario', + 'admin.newPassword': 'Nueva contraseña', + 'admin.newPasswordHint': 'Déjalo vacío para mantener la contraseña actual', + 'admin.deleteUser': '¿Eliminar al usuario "{name}"? Todos sus viajes se borrarán permanentemente.', + 'admin.deleteUserTitle': 'Eliminar usuario', + 'admin.newPasswordPlaceholder': 'Introduce una nueva contraseña…', + 'admin.toast.loadError': 'No se pudieron cargar los datos de administración', + 'admin.toast.userUpdated': 'Usuario actualizado', + 'admin.toast.updateError': 'No se pudo actualizar', + 'admin.toast.userDeleted': 'Usuario eliminado', + 'admin.toast.deleteError': 'No se pudo eliminar', + 'admin.toast.cannotDeleteSelf': 'No puedes eliminar tu propia cuenta', + 'admin.toast.userCreated': 'Usuario creado', + 'admin.toast.createError': 'No se pudo crear el usuario', + 'admin.toast.fieldsRequired': 'Usuario, correo y contraseña son obligatorios', + 'admin.createUser': 'Crear usuario', + 'admin.tabs.settings': 'Ajustes', + 'admin.allowRegistration': 'Permitir el registro', + 'admin.allowRegistrationHint': 'Los nuevos usuarios pueden registrarse por sí mismos', + 'admin.apiKeys': 'Claves API', + 'admin.apiKeysHint': 'Opcional. Activa datos ampliados de lugares, como fotos y previsión del tiempo.', + 'admin.mapsKey': 'Clave API de Google Maps', + 'admin.mapsKeyHint': 'Obligatoria para buscar lugares. Consíguela en console.cloud.google.com', + 'admin.mapsKeyHintLong': 'Sin una clave API, la búsqueda de lugares usa OpenStreetMap. Con una clave de Google también se pueden cargar fotos, valoraciones y horarios de apertura. Consíguela en console.cloud.google.com.', + 'admin.recommended': 'Recomendado', + 'admin.weatherKey': 'Clave API de OpenWeatherMap', + 'admin.weatherKeyHint': 'Para datos meteorológicos. Gratis en openweathermap.org', + 'admin.validateKey': 'Probar', + 'admin.keyValid': 'Conectado', + 'admin.keyInvalid': 'No válida', + 'admin.keySaved': 'Claves API guardadas', + 'admin.oidcTitle': 'Inicio de sesión único (OIDC)', + 'admin.oidcSubtitle': 'Permite iniciar sesión mediante proveedores externos como Google, Apple, Authentik o Keycloak.', + 'admin.oidcDisplayName': 'Nombre visible', + 'admin.oidcIssuer': 'URL del emisor', + 'admin.oidcIssuerHint': 'La URL Issuer de OpenID Connect del proveedor. Ej.: https://accounts.google.com', + 'admin.oidcSaved': 'Configuración OIDC guardada', + + // File Types + 'admin.fileTypes': 'Tipos de archivo permitidos', + 'admin.fileTypesHint': 'Configura qué tipos de archivo pueden subir los usuarios.', + 'admin.fileTypesFormat': 'Extensiones separadas por comas (p. ej. jpg,png,pdf,doc). Usa * para permitir todos los tipos.', + 'admin.fileTypesSaved': 'Ajustes de tipos de archivo guardados', + + // Addons + 'admin.tabs.addons': 'Complementos', + 'admin.addons.title': 'Complementos', + 'admin.addons.subtitle': 'Activa o desactiva funciones para personalizar tu experiencia en NOMAD.', + 'admin.addons.subtitleBefore': 'Activa o desactiva funciones para personalizar tu experiencia en ', + 'admin.addons.subtitleAfter': '.', + 'admin.addons.enabled': 'Activo', + 'admin.addons.disabled': 'Desactivado', + 'admin.addons.type.trip': 'Viaje', + 'admin.addons.type.global': 'Global', + 'admin.addons.tripHint': 'Disponible como pestaña dentro de cada viaje', + 'admin.addons.globalHint': 'Disponible como sección independiente en la navegación principal', + 'admin.addons.toast.updated': 'Complemento actualizado', + 'admin.addons.toast.error': 'No se pudo actualizar el complemento', + 'admin.addons.noAddons': 'No hay complementos disponibles', + 'admin.weather.title': 'Datos meteorológicos', + 'admin.weather.badge': 'Desde el 24 de marzo de 2026', + 'admin.weather.description': 'NOMAD utiliza Open-Meteo como fuente de datos meteorológicos. Open-Meteo es un servicio meteorológico gratuito y de código abierto: no requiere clave API.', + 'admin.weather.forecast': 'Pronóstico de 16 días', + 'admin.weather.forecastDesc': 'Antes eran 5 días (OpenWeatherMap)', + 'admin.weather.climate': 'Datos climáticos históricos', + 'admin.weather.climateDesc': 'Promedios de los últimos 85 años para fechas posteriores al pronóstico de 16 días', + 'admin.weather.requests': '10.000 solicitudes / día', + 'admin.weather.requestsDesc': 'Gratis, sin necesidad de clave API', + 'admin.weather.locationHint': 'El tiempo se basa en el primer lugar con coordenadas de cada día. Si no hay ningún lugar asignado a un día, se usa como referencia cualquier lugar de la lista.', + + // GitHub + 'admin.tabs.github': 'GitHub', + 'admin.github.title': 'Historial de versiones', + 'admin.github.subtitle': 'Últimas novedades de {repo}', + 'admin.github.latest': 'Última', + 'admin.github.prerelease': 'Prelanzamiento', + 'admin.github.showDetails': 'Mostrar detalles', + 'admin.github.hideDetails': 'Ocultar detalles', + 'admin.github.loadMore': 'Cargar más', + 'admin.github.loading': 'Cargando...', + 'admin.github.error': 'No se pudieron cargar las versiones', + 'admin.github.by': 'por', + 'admin.update.available': 'Actualización disponible', + 'admin.update.text': 'NOMAD {version} está disponible. Estás usando {current}.', + 'admin.update.button': 'Ver en GitHub', + 'admin.update.install': 'Instalar actualización', + 'admin.update.confirmTitle': '¿Instalar actualización?', + 'admin.update.confirmText': 'NOMAD se actualizará de {current} a {version}. Después, el servidor se reiniciará automáticamente.', + 'admin.update.dataInfo': 'Todos tus datos (viajes, usuarios, claves API, subidas, Vacay, Atlas, presupuestos) se conservarán.', + 'admin.update.warning': 'La app estará brevemente no disponible durante el reinicio.', + 'admin.update.confirm': 'Actualizar ahora', + 'admin.update.installing': 'Actualizando…', + 'admin.update.success': '¡Actualización instalada! El servidor se está reiniciando…', + 'admin.update.failed': 'La actualización falló', + 'admin.update.backupHint': 'Recomendamos crear una copia de seguridad antes de actualizar.', + 'admin.update.backupLink': 'Ir a Copia de seguridad', + 'admin.update.howTo': 'Cómo actualizar', + 'admin.update.dockerText': 'Tu instancia de NOMAD se ejecuta en Docker. Para actualizar a {version}, ejecuta los siguientes comandos en tu servidor:', + 'admin.update.reloadHint': 'Recarga la página en unos segundos.', + + // Vacay addon + 'vacay.subtitle': 'Planifica y gestiona días de vacaciones', + 'vacay.settings': 'Ajustes', + 'vacay.year': 'Año', + 'vacay.addYear': 'Añadir año', + 'vacay.removeYear': 'Eliminar año', + 'vacay.removeYearConfirm': '¿Eliminar {year}?', + 'vacay.removeYearHint': 'Todas las vacaciones y festivos de empresa de este año se borrarán permanentemente.', + 'vacay.remove': 'Eliminar', + 'vacay.persons': 'Personas', + 'vacay.noPersons': 'No se han añadido personas', + 'vacay.addPerson': 'Añadir persona', + 'vacay.editPerson': 'Editar persona', + 'vacay.removePerson': 'Eliminar persona', + 'vacay.removePersonConfirm': '¿Eliminar a {name}?', + 'vacay.removePersonHint': 'Todas las vacaciones de esta persona se borrarán permanentemente.', + 'vacay.personName': 'Nombre', + 'vacay.personNamePlaceholder': 'Introduce un nombre', + 'vacay.color': 'Color', + 'vacay.add': 'Añadir', + 'vacay.legend': 'Leyenda', + 'vacay.publicHoliday': 'Festivo', + 'vacay.companyHoliday': 'Festivo de empresa', + 'vacay.weekend': 'Fin de semana', + 'vacay.modeVacation': 'Vacaciones', + 'vacay.modeCompany': 'Festivo de empresa', + 'vacay.entitlement': 'Derecho', + 'vacay.entitlementDays': 'Días', + 'vacay.used': 'Usados', + 'vacay.remaining': 'Restantes', + 'vacay.carriedOver': 'de {year}', + 'vacay.blockWeekends': 'Bloquear fines de semana', + 'vacay.blockWeekendsHint': 'Impide marcar vacaciones en sábados y domingos', + 'vacay.publicHolidays': 'Festivos', + 'vacay.publicHolidaysHint': 'Marcar festivos en el calendario', + 'vacay.selectCountry': 'Seleccionar país', + 'vacay.selectRegion': 'Seleccionar región (opcional)', + 'vacay.companyHolidays': 'Festivos de empresa', + 'vacay.companyHolidaysHint': 'Permitir marcar días festivos comunes de la empresa', + 'vacay.companyHolidaysNoDeduct': 'Los festivos de empresa no descuentan días de vacaciones.', + 'vacay.carryOver': 'Arrastrar saldo', + 'vacay.carryOverHint': 'Trasladar automáticamente los días restantes al año siguiente', + 'vacay.sharing': 'Compartir', + 'vacay.sharingHint': 'Comparte tu calendario de vacaciones con otros usuarios de NOMAD', + 'vacay.owner': 'Propietario', + 'vacay.shareEmailPlaceholder': 'Correo electrónico del usuario de NOMAD', + 'vacay.shareSuccess': 'Plan compartido correctamente', + 'vacay.shareError': 'No se pudo compartir el plan', + 'vacay.dissolve': 'Deshacer fusión', + 'vacay.dissolveHint': 'Separar de nuevo los calendarios. Tus entradas se conservarán.', + 'vacay.dissolveAction': 'Disolver', + 'vacay.dissolved': 'Calendario separado', + 'vacay.fusedWith': 'Fusionado con', + 'vacay.you': 'tú', + 'vacay.noData': 'Sin datos', + 'vacay.changeColor': 'Cambiar color', + 'vacay.inviteUser': 'Invitar usuario', + 'vacay.inviteHint': 'Invita a otro usuario de NOMAD a compartir un calendario combinado de vacaciones.', + 'vacay.selectUser': 'Seleccionar usuario', + 'vacay.sendInvite': 'Enviar invitación', + 'vacay.inviteSent': 'Invitación enviada', + 'vacay.inviteError': 'No se pudo enviar la invitación', + 'vacay.pending': 'pendiente', + 'vacay.noUsersAvailable': 'No hay usuarios disponibles', + 'vacay.accept': 'Aceptar', + 'vacay.decline': 'Rechazar', + 'vacay.acceptFusion': 'Aceptar y fusionar', + 'vacay.inviteTitle': 'Solicitud de fusión', + 'vacay.inviteWantsToFuse': 'quiere compartir un calendario de vacaciones contigo.', + 'vacay.fuseInfo1': 'Ambos veréis todas las entradas de vacaciones en un único calendario compartido.', + 'vacay.fuseInfo2': 'Ambas partes pueden crear y editar entradas mutuamente.', + 'vacay.fuseInfo3': 'Ambas partes pueden borrar entradas y cambiar el número de días de vacaciones disponibles.', + 'vacay.fuseInfo4': 'Ajustes como festivos y festivos de empresa se comparten.', + 'vacay.fuseInfo5': 'La fusión puede disolverse en cualquier momento por cualquiera de las partes. Tus entradas se conservarán.', + + // Atlas addon + 'atlas.subtitle': 'Tu huella viajera por el mundo', + 'atlas.countries': 'Países', + 'atlas.trips': 'Viajes', + 'atlas.places': 'Lugares', + 'atlas.days': 'Días', + 'atlas.visitedCountries': 'Países visitados', + 'atlas.cities': 'Ciudades', + 'atlas.noData': 'Aún no hay datos de viaje', + 'atlas.noDataHint': 'Crea un viaje y añade lugares para ver tu mapa del mundo', + 'atlas.lastTrip': 'Último viaje', + 'atlas.nextTrip': 'Próximo viaje', + 'atlas.daysLeft': 'días restantes', + 'atlas.streak': 'Racha', + 'atlas.year': 'año', + 'atlas.years': 'años', + 'atlas.yearInRow': 'año seguido', + 'atlas.yearsInRow': 'años seguidos', + 'atlas.tripIn': 'viaje en', + 'atlas.tripsIn': 'viajes en', + 'atlas.since': 'desde', + 'atlas.europe': 'Europa', + 'atlas.asia': 'Asia', + 'atlas.northAmerica': 'América del Norte', + 'atlas.southAmerica': 'América del Sur', + 'atlas.africa': 'África', + 'atlas.oceania': 'Oceanía', + 'atlas.other': 'Otros', + 'atlas.firstVisit': 'Primer viaje', + 'atlas.lastVisitLabel': 'Último viaje', + 'atlas.tripSingular': 'Viaje', + 'atlas.tripPlural': 'Viajes', + 'atlas.placeVisited': 'Lugar visitado', + 'atlas.placesVisited': 'Lugares visitados', + + // Trip Planner + 'trip.tabs.plan': 'Plan', + 'trip.tabs.reservations': 'Reservas', + 'trip.tabs.reservationsShort': 'Reservas', + 'trip.tabs.packing': 'Lista de equipaje', + 'trip.tabs.packingShort': 'Equipaje', + 'trip.tabs.budget': 'Presupuesto', + 'trip.tabs.memories': 'Recuerdos', + 'trip.tabs.files': 'Archivos', + 'trip.loading': 'Cargando viaje...', + 'trip.mobilePlan': 'Plan', + 'trip.mobilePlaces': 'Lugares', + 'trip.toast.placeUpdated': 'Lugar actualizado', + 'trip.toast.placeAdded': 'Lugar añadido', + 'trip.toast.placeDeleted': 'Lugar eliminado', + 'trip.toast.selectDay': 'Selecciona primero un día', + 'trip.toast.assignedToDay': 'Lugar asignado al día', + 'trip.toast.reorderError': 'No se pudo reordenar', + 'trip.toast.reservationUpdated': 'Reserva actualizada', + 'trip.toast.reservationAdded': 'Reserva añadida', + 'trip.toast.deleted': 'Eliminado', + 'trip.confirm.deletePlace': '¿Seguro que quieres eliminar este lugar?', + + // Day Plan Sidebar + 'dayplan.emptyDay': 'No hay lugares planificados para este día', + 'dayplan.addNote': 'Añadir nota', + 'dayplan.editNote': 'Editar nota', + 'dayplan.noteAdd': 'Añadir nota', + 'dayplan.noteEdit': 'Editar nota', + 'dayplan.noteTitle': 'Nota', + 'dayplan.noteSubtitle': 'Nota diaria', + 'dayplan.totalCost': 'Coste total', + 'dayplan.days': 'Días', + 'dayplan.dayN': 'Día {n}', + 'dayplan.calculating': 'Calculando...', + 'dayplan.route': 'Ruta', + 'dayplan.optimize': 'Optimizar', + 'dayplan.optimized': 'Ruta optimizada', + 'dayplan.routeError': 'No se pudo calcular la ruta', + 'dayplan.toast.needTwoPlaces': 'Se necesitan al menos dos lugares para optimizar la ruta', + 'dayplan.toast.routeOptimized': 'Ruta optimizada', + 'dayplan.toast.noGeoPlaces': 'No se encontraron lugares con coordenadas para calcular la ruta', + 'dayplan.confirmed': 'Confirmado', + 'dayplan.pendingRes': 'Pendiente', + 'dayplan.pdf': 'PDF', + 'dayplan.pdfTooltip': 'Exportar plan diario como PDF', + 'dayplan.pdfError': 'No se pudo exportar el PDF', + + // Places Sidebar + 'places.addPlace': 'Añadir lugar/actividad', + 'places.assignToDay': '¿A qué día añadirlo?', + 'places.all': 'Todo', + 'places.unplanned': 'Sin planificar', + 'places.search': 'Buscar lugares...', + 'places.allCategories': 'Todas las categorías', + 'places.count': '{count} lugares', + 'places.countSingular': '1 lugar', + 'places.allPlanned': 'Todos los lugares están planificados', + 'places.noneFound': 'No se encontraron lugares', + 'places.editPlace': 'Editar lugar', + 'places.formName': 'Nombre', + 'places.formNamePlaceholder': 'p. ej. Torre Eiffel', + 'places.formDescription': 'Descripción', + 'places.formDescriptionPlaceholder': 'Descripción breve...', + 'places.formAddress': 'Dirección', + 'places.formAddressPlaceholder': 'Calle, ciudad, país', + 'places.formLat': 'Latitud (p. ej. 48.8566)', + 'places.formLng': 'Longitud (p. ej. 2.3522)', + 'places.formCategory': 'Categoría', + 'places.noCategory': 'Sin categoría', + 'places.categoryNamePlaceholder': 'Nombre de la categoría', + 'places.formTime': 'Hora', + 'places.startTime': 'Inicio', + 'places.endTime': 'Fin', + 'places.endTimeBeforeStart': 'La hora de fin es anterior a la de inicio', + 'places.timeCollision': 'Solapamiento horario con:', + 'places.formWebsite': 'Página web', + 'places.formNotesPlaceholder': 'Notas personales...', + 'places.formReservation': 'Reserva', + 'places.reservationNotesPlaceholder': 'Notas de reserva, número de confirmación...', + 'places.mapsSearchPlaceholder': 'Buscar lugares...', + 'places.mapsSearchError': 'La búsqueda de lugares falló.', + 'places.osmHint': 'Usando búsqueda con OpenStreetMap (sin fotos, horarios ni valoraciones). Añade una clave API de Google en Ajustes para obtener todos los detalles.', + 'places.osmActive': 'Búsqueda mediante OpenStreetMap (sin fotos, valoraciones ni horarios). Añade una clave API de Google en Ajustes para datos ampliados.', + 'places.categoryCreateError': 'No se pudo crear la categoría', + 'places.nameRequired': 'Introduce un nombre', + 'places.saveError': 'No se pudo guardar', + + // Place Inspector + 'inspector.opened': 'Abierto', + 'inspector.closed': 'Cerrado', + 'inspector.openingHours': 'Horario de apertura', + 'inspector.showHours': 'Mostrar horario', + 'inspector.files': 'Archivos', + 'inspector.filesCount': '{count} archivos', + 'inspector.removeFromDay': 'Quitar del día', + 'inspector.addToDay': 'Añadir al día', + 'inspector.confirmedRes': 'Reserva confirmada', + 'inspector.pendingRes': 'Reserva pendiente', + 'inspector.google': 'Abrir en Google Maps', + 'inspector.website': 'Abrir la web', + 'inspector.addRes': 'Reserva', + 'inspector.editRes': 'Editar reserva', + 'inspector.participants': 'Participantes', + + // Reservations + 'reservations.title': 'Reservas', + 'reservations.empty': 'Aún no hay reservas', + 'reservations.emptyHint': 'Añade reservas de vuelos, hoteles y más', + 'reservations.add': 'Añadir reserva', + 'reservations.addManual': 'Reserva manual', + 'reservations.placeHint': 'Consejo: es mejor crear las reservas directamente desde un lugar para vincularlas con el plan del día.', + 'reservations.confirmed': 'Confirmada', + 'reservations.pending': 'Pendiente', + 'reservations.summary': '{confirmed} confirmadas, {pending} pendientes', + 'reservations.fromPlan': 'Del plan', + 'reservations.showFiles': 'Mostrar archivos', + 'reservations.editTitle': 'Editar reserva', + 'reservations.status': 'Estado', + 'reservations.datetime': 'Fecha y hora', + 'reservations.startTime': 'Hora de inicio', + 'reservations.endTime': 'Hora de fin', + 'reservations.date': 'Fecha', + 'reservations.time': 'Hora', + 'reservations.timeAlt': 'Hora (alternativa, p. ej. 19:30)', + 'reservations.notes': 'Notas', + 'reservations.notesPlaceholder': 'Notas adicionales...', + 'reservations.type.flight': 'Vuelo', + 'reservations.type.hotel': 'Hotel', + 'reservations.type.restaurant': 'Restaurante', + 'reservations.type.train': 'Tren', + 'reservations.type.car': 'Coche de alquiler', + 'reservations.type.cruise': 'Crucero', + 'reservations.type.event': 'Evento', + 'reservations.type.tour': 'Tour', + 'reservations.type.other': 'Otro', + 'reservations.confirm.delete': '¿Seguro que quieres eliminar la reserva "{name}"?', + 'reservations.toast.updated': 'Reserva actualizada', + 'reservations.toast.removed': 'Reserva eliminada', + 'reservations.toast.fileUploaded': 'Archivo subido', + 'reservations.toast.uploadError': 'No se pudo subir', + 'reservations.newTitle': 'Nueva reserva', + 'reservations.bookingType': 'Tipo de reserva', + 'reservations.titleLabel': 'Título', + 'reservations.titlePlaceholder': 'p. ej. Lufthansa LH123, Hotel Adlon, ...', + 'reservations.locationAddress': 'Ubicación / dirección', + 'reservations.locationPlaceholder': 'Dirección, aeropuerto, hotel...', + 'reservations.confirmationCode': 'Código de reserva', + 'reservations.confirmationPlaceholder': 'p. ej. ABC12345', + 'reservations.day': 'Día', + 'reservations.noDay': 'Sin día', + 'reservations.place': 'Lugar', + 'reservations.noPlace': 'Sin lugar', + 'reservations.pendingSave': 'se guardará…', + 'reservations.uploading': 'Subiendo...', + 'reservations.attachFile': 'Adjuntar archivo', + 'reservations.toast.saveError': 'No se pudo guardar', + 'reservations.toast.updateError': 'No se pudo actualizar', + 'reservations.toast.deleteError': 'No se pudo eliminar', + 'reservations.confirm.remove': '¿Eliminar la reserva de "{name}"?', + 'reservations.linkAssignment': 'Vincular a una asignación del día', + 'reservations.pickAssignment': 'Selecciona una asignación de tu plan...', + 'reservations.noAssignment': 'Sin vínculo (independiente)', + + // Budget + 'budget.title': 'Presupuesto', + 'budget.emptyTitle': 'Aún no se ha creado ningún presupuesto', + 'budget.emptyText': 'Crea categorías y entradas para planificar el presupuesto de tu viaje', + 'budget.emptyPlaceholder': 'Introduce el nombre de la categoría...', + 'budget.createCategory': 'Crear categoría', + 'budget.category': 'Categoría', + 'budget.categoryName': 'Nombre de la categoría', + 'budget.table.name': 'Nombre', + 'budget.table.total': 'Total', + 'budget.table.persons': 'Personas', + 'budget.table.days': 'Días', + 'budget.table.perPerson': 'Por persona', + 'budget.table.perDay': 'Por día', + 'budget.table.perPersonDay': 'Por pers. / día', + 'budget.table.note': 'Nota', + 'budget.newEntry': 'Nueva entrada', + 'budget.defaultEntry': 'Nueva entrada', + 'budget.defaultCategory': 'Nueva categoría', + 'budget.total': 'Total', + 'budget.totalBudget': 'Presupuesto total', + 'budget.byCategory': 'Por categoría', + 'budget.editTooltip': 'Haz clic para editar', + 'budget.confirm.deleteCategory': '¿Seguro que quieres eliminar la categoría "{name}" con {count} entradas?', + 'budget.deleteCategory': 'Eliminar categoría', + 'budget.perPerson': 'Por persona', + 'budget.paid': 'Pagado', + 'budget.open': 'Abrir', + 'budget.noMembers': 'No hay miembros asignados', + + // Files + 'files.title': 'Archivos', + 'files.count': '{count} archivos', + 'files.countSingular': '1 archivo', + 'files.uploaded': '{count} archivos subidos', + 'files.uploadError': 'La subida falló', + 'files.dropzone': 'Arrastra aquí los archivos', + 'files.dropzoneHint': 'o haz clic para explorar', + 'files.allowedTypes': 'Imágenes, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Máx. 50 MB', + 'files.uploading': 'Subiendo...', + 'files.filterAll': 'Todo', + 'files.filterPdf': 'PDF', + 'files.filterImages': 'Imágenes', + 'files.filterDocs': 'Documentos', + 'files.filterCollab': 'Notas de colaboración', + 'files.sourceCollab': 'Desde notas de colaboración', + 'files.empty': 'Aún no hay archivos', + 'files.emptyHint': 'Sube archivos para adjuntarlos a tu viaje', + 'files.openTab': 'Abrir en una pestaña nueva', + 'files.confirm.delete': '¿Seguro que quieres eliminar este archivo?', + 'files.toast.deleted': 'Archivo eliminado', + 'files.toast.deleteError': 'No se pudo eliminar el archivo', + 'files.sourcePlan': 'Plan diario', + 'files.sourceBooking': 'Reserva', + 'files.attach': 'Adjuntar', + 'files.pasteHint': 'También puedes pegar imágenes desde el portapapeles (Ctrl+V)', + + // Packing + 'packing.title': 'Lista de equipaje', + 'packing.empty': 'La lista de equipaje está vacía', + 'packing.progress': '{packed} de {total} preparados ({percent}%)', + 'packing.clearChecked': 'Eliminar {count} marcados', + 'packing.clearCheckedShort': 'Eliminar {count}', + 'packing.suggestions': 'Sugerencias', + 'packing.suggestionsTitle': 'Añadir sugerencias', + 'packing.allSuggested': 'Todas las sugerencias añadidas', + 'packing.allPacked': '¡Todo preparado!', + 'packing.addPlaceholder': 'Añadir nuevo elemento...', + 'packing.categoryPlaceholder': 'Categoría...', + 'packing.filterAll': 'Todo', + 'packing.filterOpen': 'Pendientes', + 'packing.filterDone': 'Hecho', + 'packing.emptyTitle': 'La lista de equipaje está vacía', + 'packing.emptyHint': 'Añade elementos o usa las sugerencias', + 'packing.emptyFiltered': 'Ningún elemento coincide con este filtro', + 'packing.menuRename': 'Renombrar', + 'packing.menuCheckAll': 'Marcar todo', + 'packing.menuUncheckAll': 'Desmarcar todo', + 'packing.menuDeleteCat': 'Eliminar categoría', + 'packing.changeCategory': 'Cambiar categoría', + 'packing.confirm.clearChecked': '¿Seguro que quieres eliminar {count} elementos marcados?', + 'packing.confirm.deleteCat': '¿Seguro que quieres eliminar la categoría "{name}" con {count} elementos?', + 'packing.defaultCategory': 'Otros', + 'packing.toast.saveError': 'No se pudo guardar', + 'packing.toast.deleteError': 'No se pudo eliminar', + 'packing.toast.renameError': 'No se pudo renombrar', + 'packing.toast.addError': 'No se pudo añadir', + + // Packing suggestions + 'packing.suggestions.items': [ + { name: 'Pasaporte', category: 'Documentos' }, + { name: 'Documento de identidad', category: 'Documentos' }, + { name: 'Seguro de viaje', category: 'Documentos' }, + { name: 'Billetes de vuelo', category: 'Documentos' }, + { name: 'Tarjeta de crédito', category: 'Finanzas' }, + { name: 'Efectivo', category: 'Finanzas' }, + { name: 'Visado', category: 'Documentos' }, + { name: 'Camisetas', category: 'Ropa' }, + { name: 'Pantalones', category: 'Ropa' }, + { name: 'Ropa interior', category: 'Ropa' }, + { name: 'Calcetines', category: 'Ropa' }, + { name: 'Chaqueta', category: 'Ropa' }, + { name: 'Pijama', category: 'Ropa' }, + { name: 'Ropa de baño', category: 'Ropa' }, + { name: 'Impermeable', category: 'Ropa' }, + { name: 'Zapatos cómodos', category: 'Ropa' }, + { name: 'Cepillo de dientes', category: 'Aseo' }, + { name: 'Pasta de dientes', category: 'Aseo' }, + { name: 'Champú', category: 'Aseo' }, + { name: 'Desodorante', category: 'Aseo' }, + { name: 'Protector solar', category: 'Aseo' }, + { name: 'Maquinilla de afeitar', category: 'Aseo' }, + { name: 'Cargador', category: 'Electrónica' }, + { name: 'Batería externa', category: 'Electrónica' }, + { name: 'Auriculares', category: 'Electrónica' }, + { name: 'Adaptador de viaje', category: 'Electrónica' }, + { name: 'Cámara', category: 'Electrónica' }, + { name: 'Analgésicos', category: 'Salud' }, + { name: 'Tiritas', category: 'Salud' }, + { name: 'Desinfectante', category: 'Salud' }, + ], + + // Members / Sharing + 'members.shareTrip': 'Compartir viaje', + 'members.inviteUser': 'Invitar usuario', + 'members.selectUser': 'Seleccionar usuario…', + 'members.invite': 'Invitar', + 'members.allHaveAccess': 'Todos los usuarios ya tienen acceso.', + 'members.access': 'Acceso', + 'members.person': 'persona', + 'members.persons': 'personas', + 'members.you': 'tú', + 'members.owner': 'Propietario', + 'members.leaveTrip': 'Abandonar viaje', + 'members.removeAccess': 'Quitar acceso', + 'members.confirmLeave': '¿Abandonar el viaje? Perderás el acceso.', + 'members.confirmRemove': '¿Quitar el acceso de este usuario?', + 'members.loadError': 'No se pudieron cargar los miembros', + 'members.added': 'añadido', + 'members.addError': 'No se pudo añadir', + 'members.removed': 'Miembro eliminado', + 'members.removeError': 'No se pudo eliminar', + + // Categories (Admin) + 'categories.title': 'Categorías', + 'categories.subtitle': 'Gestiona categorías para lugares', + 'categories.new': 'Nueva categoría', + 'categories.empty': 'Aún no hay categorías', + 'categories.namePlaceholder': 'Nombre de la categoría', + 'categories.icon': 'Icono', + 'categories.color': 'Color', + 'categories.customColor': 'Elegir color personalizado', + 'categories.preview': 'Vista previa', + 'categories.defaultName': 'Categoría', + 'categories.update': 'Actualizar', + 'categories.create': 'Crear', + 'categories.confirm.delete': '¿Eliminar la categoría? Los lugares de esta categoría no se eliminarán.', + 'categories.toast.loadError': 'No se pudieron cargar las categorías', + 'categories.toast.nameRequired': 'Introduce un nombre', + 'categories.toast.updated': 'Categoría actualizada', + 'categories.toast.created': 'Categoría creada', + 'categories.toast.saveError': 'No se pudo guardar', + 'categories.toast.deleted': 'Categoría eliminada', + 'categories.toast.deleteError': 'No se pudo eliminar', + + // Backup (Admin) + 'backup.title': 'Copia de seguridad de datos', + 'backup.subtitle': 'Base de datos y todos los archivos subidos', + 'backup.refresh': 'Actualizar', + 'backup.upload': 'Subir copia de seguridad', + 'backup.uploading': 'Subiendo…', + 'backup.create': 'Crear copia', + 'backup.creating': 'Creando…', + 'backup.empty': 'Aún no hay copias', + 'backup.createFirst': 'Crear la primera copia', + 'backup.download': 'Descargar', + 'backup.restore': 'Restaurar', + 'backup.confirm.restore': '¿Restaurar la copia "{name}"?\n\nTodos los datos actuales serán reemplazados por la copia.', + 'backup.confirm.uploadRestore': '¿Subir y restaurar el archivo de copia "{name}"?\n\nTodos los datos actuales se sobrescribirán.', + 'backup.confirm.delete': '¿Eliminar la copia "{name}"?', + 'backup.toast.loadError': 'No se pudieron cargar las copias', + 'backup.toast.created': 'Copia de seguridad creada correctamente', + 'backup.toast.createError': 'No se pudo crear la copia', + 'backup.toast.restored': 'Copia restaurada. La página se recargará…', + 'backup.toast.restoreError': 'No se pudo restaurar', + 'backup.toast.uploadError': 'No se pudo subir', + 'backup.toast.deleted': 'Copia eliminada', + 'backup.toast.deleteError': 'No se pudo eliminar', + 'backup.toast.downloadError': 'La descarga falló', + 'backup.toast.settingsSaved': 'Ajustes de copia automática guardados', + 'backup.toast.settingsError': 'No se pudieron guardar los ajustes', + 'backup.auto.title': 'Copia automática', + 'backup.auto.subtitle': 'Copia de seguridad automática según una programación', + 'backup.auto.enable': 'Activar copia automática', + 'backup.auto.enableHint': 'Se crearán copias automáticamente según la frecuencia elegida', + 'backup.auto.interval': 'Intervalo', + 'backup.auto.keepLabel': 'Eliminar copias antiguas después de', + 'backup.interval.hourly': 'Cada hora', + 'backup.interval.daily': 'Diaria', + 'backup.interval.weekly': 'Semanal', + 'backup.interval.monthly': 'Mensual', + 'backup.keep.1day': '1 día', + 'backup.keep.3days': '3 días', + 'backup.keep.7days': '7 días', + 'backup.keep.14days': '14 días', + 'backup.keep.30days': '30 días', + 'backup.keep.forever': 'Conservar para siempre', + + // Photos + 'photos.allDays': 'Todos los días', + 'photos.title': 'Recuerdos', + 'photos.noPhotos': 'Aún no hay fotos', + 'photos.uploadHint': 'Sube y organiza las fotos compartidas de este viaje', + 'photos.clickToSelect': 'o haz clic para seleccionar', + 'photos.dropHere': 'Suelta aquí las fotos...', + 'photos.dropTitle': 'Suelta aquí las fotos', + 'photos.fileHint': 'JPG, PNG, GIF, WebP · máx. 10 MB · hasta 30 fotos', + 'photos.selectedCount': '{count} foto(s) seleccionada(s)', + 'photos.sharedAlbum': '{count} recuerdos en este álbum compartido', + 'photos.sharedAlbumFor': '{count} recuerdos en {trip}', + 'photos.allPlaces': 'Todos los lugares', + 'photos.view.grid': 'Cuadrícula', + 'photos.view.day': 'Por día', + 'photos.view.place': 'Por lugar', + 'photos.stats.total': 'Fotos', + 'photos.stats.days': 'Días', + 'photos.stats.places': 'Lugares', + 'photos.stats.latest': 'Última subida', + 'photos.sectionCount': '{count} foto(s)', + 'photos.ungrouped': 'Sin clasificar', + 'photos.featured': 'Recuerdo destacado', + 'photos.coverFallback': 'Portada del álbum compartido', + 'photos.coverHint': 'Una imagen destacada para este álbum de viaje', + 'photos.mapTitle': 'Mapa de recuerdos', + 'photos.mapHint': 'Explora los lugares vinculados en el mismo mapa que usamos en el plan', + 'photos.mapEmpty': 'Vincula tus fotos a lugares para verlas ubicadas en el mapa.', + 'photos.linkDay': 'Vincular día', + 'photos.noDay': 'Sin día', + 'photos.linkPlace': 'Vincular lugar', + 'photos.noPlace': 'Sin lugar', + 'photos.captionLabel': 'Pie de foto (para todas)', + 'photos.captionPlaceholder': 'Pie de foto opcional...', + 'photos.addCaption': 'Añadir un pie de foto...', + 'photos.uploadN': 'Subida de {n} foto(s)', + 'admin.addons.catalog.memories.name': 'Recuerdos', + 'admin.addons.catalog.memories.description': 'Álbumes de fotos compartidos para cada viaje', + 'admin.addons.catalog.packing.name': 'Equipaje', + 'admin.addons.catalog.packing.description': 'Prepara tu equipaje con listas de comprobación para cada viaje', + 'admin.addons.catalog.budget.name': 'Presupuesto', + 'admin.addons.catalog.budget.description': 'Controla los gastos y planifica el presupuesto del viaje', + 'admin.addons.catalog.documents.name': 'Documentos', + 'admin.addons.catalog.documents.description': 'Guarda y gestiona la documentación del viaje', + 'admin.addons.catalog.vacay.name': 'Vacaciones', + 'admin.addons.catalog.vacay.description': 'Planificador personal de vacaciones con vista de calendario', + 'admin.addons.catalog.atlas.name': 'Atlas', + 'admin.addons.catalog.atlas.description': 'Mapa del mundo con los países visitados y estadísticas de viaje', + 'admin.addons.catalog.collab.name': 'Colaboración', + 'admin.addons.catalog.collab.description': 'Notas, encuestas y chat en tiempo real para organizar el viaje', + + // Backup restore modal + 'backup.restoreConfirmTitle': '¿Restaurar copia?', + 'backup.restoreWarning': 'Todos los datos actuales (viajes, lugares, usuarios, subidas) serán reemplazados permanentemente por la copia. Esta acción no se puede deshacer.', + 'backup.restoreTip': 'Consejo: crea una copia del estado actual antes de restaurar.', + 'backup.restoreConfirm': 'Sí, restaurar', + + // PDF + 'pdf.travelPlan': 'Plan de viaje', + 'pdf.planned': 'Planificado', + 'pdf.costLabel': 'Coste EUR', + 'pdf.preview': 'Vista previa PDF', + 'pdf.saveAsPdf': 'Guardar como PDF', + + // Planner + 'planner.places': 'Lugares', + 'planner.bookings': 'Reservas', + 'planner.packingList': 'Lista de equipaje', + 'planner.documents': 'Documentos', + 'planner.dayPlan': 'Plan por días', + 'planner.reservations': 'Reservas', + 'planner.minTwoPlaces': 'Se necesitan al menos 2 lugares con coordenadas', + 'planner.noGeoPlaces': 'No hay lugares con coordenadas disponibles', + 'planner.routeCalculated': 'Ruta calculada', + 'planner.routeCalcFailed': 'No se pudo calcular la ruta', + 'planner.routeError': 'Error al calcular la ruta', + 'planner.routeOptimized': 'Ruta optimizada', + 'planner.reservationUpdated': 'Reserva actualizada', + 'planner.reservationAdded': 'Reserva añadida', + 'planner.confirmDeleteReservation': '¿Eliminar reserva?', + 'planner.reservationDeleted': 'Reserva eliminada', + 'planner.days': 'Días', + 'planner.allPlaces': 'Todos los lugares', + 'planner.totalPlaces': '{n} lugares en total', + 'planner.noDaysPlanned': 'Aún no hay días planificados', + 'planner.editTrip': 'Editar viaje →', + 'planner.placeOne': '1 lugar', + 'planner.placeN': '{n} lugares', + 'planner.addNote': 'Añadir nota', + 'planner.noEntries': 'No hay entradas para este día', + 'planner.addPlace': 'Añadir lugar/actividad', + 'planner.addPlaceShort': '+ Añadir lugar/actividad', + 'planner.resPending': 'Reserva pendiente · ', + 'planner.resConfirmed': 'Reserva confirmada · ', + 'planner.notePlaceholder': 'Nota…', + 'planner.noteTimePlaceholder': 'Hora (opcional)', + 'planner.noteExamplePlaceholder': 'p. ej. S3 a las 14:30 desde la estación central, ferry desde el muelle 7, pausa para comer…', + 'planner.totalCost': 'Coste total', + 'planner.searchPlaces': 'Buscar lugares…', + 'planner.allCategories': 'Todas las categorías', + 'planner.noPlacesFound': 'No se encontraron lugares', + 'planner.addFirstPlace': 'Añadir el primer lugar', + 'planner.noReservations': 'Sin reservas', + 'planner.addFirstReservation': 'Añadir la primera reserva', + 'planner.new': 'Nuevo', + 'planner.addToDay': '+ Día', + 'planner.calculating': 'Calculando…', + 'planner.route': 'Ruta', + 'planner.optimize': 'Optimizar', + 'planner.openGoogleMaps': 'Abrir en Google Maps', + 'planner.selectDayHint': 'Selecciona un día de la lista izquierda para ver su plan', + 'planner.noPlacesForDay': 'Aún no hay lugares para este día', + 'planner.addPlacesLink': 'Añadir lugares →', + 'planner.minTotal': 'min en total', + 'planner.noReservation': 'Sin reserva', + 'planner.removeFromDay': 'Quitar del día', + 'planner.addToThisDay': 'Añadir al día', + 'planner.overview': 'Vista general', + 'planner.noDays': 'No hay días todavía', + 'planner.editTripToAddDays': 'Edita el viaje para añadir días', + 'planner.dayCount': '{n} días', + 'planner.clickToUnlock': 'Haz clic para desbloquear', + 'planner.keepPosition': 'Mantener posición durante la optimización de ruta', + 'planner.dayDetails': 'Detalles del día', + 'planner.dayN': 'Día {n}', + 'planner.notes': 'Notas', + 'planner.addDayNote': 'Añadir notas para este día...', + + // Dashboard Stats + 'stats.countries': 'Países', + 'stats.cities': 'Ciudades', + 'stats.trips': 'Viajes', + 'stats.places': 'Lugares', + 'stats.worldProgress': 'Progreso mundial', + 'stats.visited': 'visitados', + 'stats.remaining': 'restantes', + 'stats.visitedCountries': 'Países visitados', + + // Day Detail Panel + 'day.precipProb': 'Probabilidad de lluvia', + 'day.precipitation': 'Precipitación', + 'day.wind': 'Viento', + 'day.sunrise': 'Amanecer', + 'day.sunset': 'Atardecer', + 'day.hourlyForecast': 'Pronóstico por horas', + 'day.climateHint': 'Promedios históricos: el pronóstico real está disponible dentro de los 16 días previos a la fecha.', + 'day.noWeather': 'No hay datos meteorológicos disponibles. Añade un lugar con coordenadas.', + 'day.overview': 'Resumen diario', + 'day.accommodation': 'Alojamiento', + 'day.addAccommodation': 'Añadir alojamiento', + 'day.hotelDayRange': 'Aplicar a los días', + 'day.noPlacesForHotel': 'Añade primero lugares al viaje', + 'day.allDays': 'Todos', + 'day.checkIn': 'Check-in', + 'day.checkOut': 'Check-out', + 'day.confirmation': 'Confirmación', + 'day.editAccommodation': 'Editar alojamiento', + 'day.reservations': 'Reservas', + + // Collab Addon + 'collab.tabs.chat': 'Mensajes', + 'collab.tabs.notes': 'Notas', + 'collab.tabs.polls': 'Encuestas', + 'collab.whatsNext.title': 'Qué viene ahora', + 'collab.whatsNext.today': 'Hoy', + 'collab.whatsNext.tomorrow': 'Mañana', + 'collab.whatsNext.empty': 'No hay actividades próximas', + 'collab.whatsNext.until': 'hasta', + 'collab.whatsNext.emptyHint': 'Las actividades con hora aparecerán aquí', + 'collab.chat.send': 'Enviar', + 'collab.chat.placeholder': 'Escribe un mensaje...', + 'collab.chat.empty': 'Empieza la conversación', + 'collab.chat.emptyHint': 'Los mensajes se comparten con todos los miembros del viaje', + 'collab.chat.emptyDesc': 'Comparte ideas, planes y novedades con tu grupo de viaje', + 'collab.chat.today': 'Hoy', + 'collab.chat.yesterday': 'Ayer', + 'collab.chat.deletedMessage': 'eliminó un mensaje', + 'collab.chat.loadMore': 'Cargar mensajes anteriores', + 'collab.chat.justNow': 'justo ahora', + 'collab.chat.minutesAgo': 'hace {n} min', + 'collab.chat.hoursAgo': 'hace {n} h', + 'collab.notes.title': 'Notas', + 'collab.notes.new': 'Nueva nota', + 'collab.notes.empty': 'Aún no hay notas', + 'collab.notes.emptyHint': 'Empieza a capturar ideas y planes', + 'collab.notes.all': 'Todas', + 'collab.notes.titlePlaceholder': 'Título de la nota', + 'collab.notes.contentPlaceholder': 'Escribe algo...', + 'collab.notes.categoryPlaceholder': 'Categoría', + 'collab.notes.newCategory': 'Nueva categoría...', + 'collab.notes.category': 'Categoría', + 'collab.notes.noCategory': 'Sin categoría', + 'collab.notes.color': 'Color', + 'collab.notes.save': 'Guardar', + 'collab.notes.cancel': 'Cancelar', + 'collab.notes.edit': 'Editar', + 'collab.notes.delete': 'Eliminar', + 'collab.notes.pin': 'Fijar', + 'collab.notes.unpin': 'Desfijar', + 'collab.notes.daysAgo': 'hace {n} d', + 'collab.notes.categorySettings': 'Gestionar categorías', + 'collab.notes.create': 'Crear', + 'collab.notes.website': 'Sitio web', + 'collab.notes.websitePlaceholder': 'https://...', + 'collab.notes.attachFiles': 'Adjuntar archivos', + 'collab.notes.noCategoriesYet': 'Aún no hay categorías', + 'collab.notes.emptyDesc': 'Crea una nota para empezar', + 'collab.polls.title': 'Encuestas', + 'collab.polls.new': 'Nueva encuesta', + 'collab.polls.empty': 'Aún no hay encuestas', + 'collab.polls.emptyHint': 'Pregunta al grupo y votad juntos', + 'collab.polls.question': 'Pregunta', + 'collab.polls.questionPlaceholder': '¿Qué deberíamos hacer?', + 'collab.polls.addOption': '+ Añadir opción', + 'collab.polls.optionPlaceholder': 'Opción {n}', + 'collab.polls.create': 'Crear encuesta', + 'collab.polls.close': 'Cerrar', + 'collab.polls.closed': 'Cerrada', + 'collab.polls.votes': '{n} votos', + 'collab.polls.vote': '{n} voto', + 'collab.polls.multipleChoice': 'Selección múltiple', + 'collab.polls.multiChoice': 'Selección múltiple', + 'collab.polls.deadline': 'Fecha límite', + 'collab.polls.option': 'Opción', + 'collab.polls.options': 'Opciones', + 'collab.polls.delete': 'Eliminar', + 'collab.polls.closedSection': 'Cerradas', + + // Files management (2.6.2) + 'files.trash': 'Papelera', + 'files.trashEmpty': 'La papelera está vacía', + 'files.emptyTrash': 'Vaciar papelera', + 'files.restore': 'Restaurar', + 'files.star': 'Destacar', + 'files.unstar': 'Quitar destacado', + 'files.assign': 'Asignar', + 'files.assignTitle': 'Asignar archivo', + 'files.assignPlace': 'Lugar', + 'files.assignBooking': 'Reserva', + 'files.unassigned': 'Sin asignar', + 'files.unlink': 'Eliminar vínculo', + 'files.noteLabel': 'Nota', + 'files.notePlaceholder': 'Añadir una nota...', + 'files.toast.trashed': 'Movido a la papelera', + 'files.toast.restored': 'Archivo restaurado', + 'files.toast.trashEmptied': 'Papelera vaciada', + 'files.toast.assigned': 'Archivo asignado', + 'files.toast.assignError': 'Error al asignar', + 'files.toast.restoreError': 'Error al restaurar', + 'files.confirm.permanentDelete': 'Eliminar este archivo permanentemente? No se puede deshacer.', + 'files.confirm.emptyTrash': 'Eliminar todos los archivos de la papelera? No se puede deshacer.', + + // Reservation metadata (2.6.2) + 'reservations.meta.airline': 'Aerolínea', + 'reservations.meta.flightNumber': 'N° de vuelo', + 'reservations.meta.from': 'Desde', + 'reservations.meta.to': 'Hasta', + 'reservations.meta.trainNumber': 'N° de tren', + 'reservations.meta.platform': 'Andén', + 'reservations.meta.seat': 'Asiento', + 'reservations.meta.checkIn': 'Check-in', + 'reservations.meta.checkOut': 'Check-out', + 'reservations.meta.linkAccommodation': 'Alojamiento', + 'reservations.meta.pickAccommodation': 'Vincular con alojamiento', + 'reservations.meta.noAccommodation': 'Ninguno', + 'reservations.meta.hotelPlace': 'Hotel', + 'reservations.meta.pickHotel': 'Seleccionar hotel', + 'reservations.meta.fromDay': 'Desde', + 'reservations.meta.toDay': 'Hasta', + 'reservations.meta.selectDay': 'Seleccionar día', + + // OIDC-only mode (2.6.2) + 'admin.oidcOnlyMode': 'Desactivar autenticación por contraseña', + 'admin.oidcOnlyModeHint': 'Si está activado, solo se permite el inicio de sesión con SSO. El inicio de sesión y registro con contraseña se bloquean.', + 'login.oidcOnly': 'La autenticación por contraseña está desactivada. Por favor, inicia sesión con tu proveedor SSO.', + + // Settings (2.6.2) + 'settings.currentPasswordRequired': 'La contraseña actual es obligatoria', + 'settings.passwordWeak': 'La contraseña debe contener mayúsculas, minúsculas y números', +} + +export default es From 31124a604a01666aa998371f8bf83734df2310d8 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 28 Mar 2026 23:11:47 +0100 Subject: [PATCH 03/13] =?UTF-8?q?feat:=20auto-split=20pasted=20lat,lng=20c?= =?UTF-8?q?oordinates=20in=20place=20form=20=E2=80=94=20closes=20#22?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/Planner/PlaceFormModal.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 6f48961..ef4ec5f 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -281,6 +281,15 @@ export default function PlaceFormModal({ step="any" value={form.lat} onChange={e => handleChange('lat', e.target.value)} + onPaste={e => { + const text = e.clipboardData.getData('text').trim() + const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/) + if (match) { + e.preventDefault() + handleChange('lat', match[1]) + handleChange('lng', match[2]) + } + }} placeholder={t('places.formLat')} className="form-input" /> From 3c4f5f71937e54457958b3f385f3414e38781b4e Mon Sep 17 00:00:00 2001 From: Stephen Wheet Date: Sat, 28 Mar 2026 22:16:12 +0000 Subject: [PATCH 04/13] feat: multiple holiday calendars per vacay plan - Add vacay_holiday_calendars table (region, label, color, sort_order) - Lazy migration of existing holidays_region to first calendar row - Extract applyHolidayCalendars() helper; replace inline holiday logic - GET /vacay/plan now includes holiday_calendars array - Add POST/PUT/DELETE /vacay/plan/holiday-calendars/:id endpoints - Client VacayPlan/VacayEntry/HolidayInfo types updated - loadHolidays() loops over all calendars; per-calendar color on HolidayInfo - VacayMonthCard uses holiday.color instead of hardcoded red - VacaySettings replaced single country picker with calendar list UI - VacayPage legend renders one item per calendar - i18n: addCalendar, calendarLabel, calendarColor, noCalendars (en + de) - Fix pre-existing TS errors: VacayPlan/VacayEntry missing fields, SettingToggleProps icon/onChange types, packing.suggestions.items array type Closes #36 --- .../src/components/Vacay/VacayMonthCard.tsx | 11 +- client/src/components/Vacay/VacaySettings.tsx | 287 +++++++++++++----- client/src/i18n/TranslationContext.tsx | 4 +- client/src/i18n/translations/de.ts | 6 +- client/src/i18n/translations/en.ts | 6 +- client/src/pages/VacayPage.tsx | 7 +- client/src/store/vacayStore.ts | 67 ++-- client/src/types.ts | 18 ++ server/src/db/schema.ts | 9 + server/src/routes/vacay.ts | 128 ++++++-- 10 files changed, 421 insertions(+), 122 deletions(-) diff --git a/client/src/components/Vacay/VacayMonthCard.tsx b/client/src/components/Vacay/VacayMonthCard.tsx index a3f7894..f1a2718 100644 --- a/client/src/components/Vacay/VacayMonthCard.tsx +++ b/client/src/components/Vacay/VacayMonthCard.tsx @@ -8,6 +8,13 @@ const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] 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'] +function hexToRgba(hex: string, alpha: number): string { + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + return `rgba(${r},${g},${b},${alpha})` +} + interface VacayMonthCardProps { year: number month: number @@ -86,7 +93,7 @@ export default function VacayMonthCard({ onMouseEnter={e => { if (!isBlocked) e.currentTarget.style.background = 'var(--bg-hover)' }} onMouseLeave={e => { e.currentTarget.style.background = weekend ? 'var(--bg-secondary)' : 'transparent' }} > - {holiday &&
} + {holiday &&
} {isCompany &&
} {dayEntries.length === 1 && ( @@ -115,7 +122,7 @@ export default function VacayMonthCard({ )} 0 ? 700 : 500, }}> {day} diff --git a/client/src/components/Vacay/VacaySettings.tsx b/client/src/components/Vacay/VacaySettings.tsx index c700eff..44a8788 100644 --- a/client/src/components/Vacay/VacaySettings.tsx +++ b/client/src/components/Vacay/VacaySettings.tsx @@ -1,10 +1,11 @@ import { useState, useEffect } from 'react' -import { MapPin, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe } from 'lucide-react' +import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2 } from 'lucide-react' import { useVacayStore } from '../../store/vacayStore' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import CustomSelect from '../shared/CustomSelect' import apiClient from '../../api/client' +import type { VacayHolidayCalendar } from '../../types' interface VacaySettingsProps { onClose: () => void @@ -13,10 +14,9 @@ interface VacaySettingsProps { export default function VacaySettings({ onClose }: VacaySettingsProps) { const { t } = useTranslation() const toast = useToast() - const { plan, updatePlan, isFused, dissolve, users } = useVacayStore() - const [countries, setCountries] = useState([]) - const [regions, setRegions] = useState([]) - const [loadingRegions, setLoadingRegions] = useState(false) + const { plan, updatePlan, addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar, isFused, dissolve, users } = useVacayStore() + const [countries, setCountries] = useState<{ value: string; label: string }[]>([]) + const [showAddForm, setShowAddForm] = useState(false) const { language } = useTranslation() @@ -34,57 +34,9 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) { }).catch(() => {}) }, [language]) - // When country changes, check if it has regions - const selectedCountry = plan?.holidays_region?.split('-')[0] || '' - const selectedRegion = plan?.holidays_region?.includes('-') ? plan.holidays_region : '' - - useEffect(() => { - if (!selectedCountry || !plan?.holidays_enabled) { setRegions([]); return } - setLoadingRegions(true) - const year = new Date().getFullYear() - apiClient.get(`/addons/vacay/holidays/${year}/${selectedCountry}`).then(r => { - const allCounties = new Set() - r.data.forEach(h => { - if (h.counties) h.counties.forEach(c => allCounties.add(c)) - }) - if (allCounties.size > 0) { - let subdivisionNames - try { subdivisionNames = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' }) } catch { /* */ } - const regionList = [...allCounties].sort().map(c => { - let label = c.split('-')[1] || c - // Try Intl for full subdivision name (not all browsers support subdivision codes) - // Fallback: use known mappings for DE - if (c.startsWith('DE-')) { - const deRegions = { BW:'Baden-Württemberg',BY:'Bayern',BE:'Berlin',BB:'Brandenburg',HB:'Bremen',HH:'Hamburg',HE:'Hessen',MV:'Mecklenburg-Vorpommern',NI:'Niedersachsen',NW:'Nordrhein-Westfalen',RP:'Rheinland-Pfalz',SL:'Saarland',SN:'Sachsen',ST:'Sachsen-Anhalt',SH:'Schleswig-Holstein',TH:'Thüringen' } - label = deRegions[c.split('-')[1]] || label - } else if (c.startsWith('CH-')) { - const chRegions = { AG:'Aargau',AI:'Appenzell Innerrhoden',AR:'Appenzell Ausserrhoden',BE:'Bern',BL:'Basel-Landschaft',BS:'Basel-Stadt',FR:'Freiburg',GE:'Genf',GL:'Glarus',GR:'Graubünden',JU:'Jura',LU:'Luzern',NE:'Neuenburg',NW:'Nidwalden',OW:'Obwalden',SG:'St. Gallen',SH:'Schaffhausen',SO:'Solothurn',SZ:'Schwyz',TG:'Thurgau',TI:'Tessin',UR:'Uri',VD:'Waadt',VS:'Wallis',ZG:'Zug',ZH:'Zürich' } - label = chRegions[c.split('-')[1]] || label - } - return { value: c, label } - }) - setRegions(regionList) - } else { - setRegions([]) - // If no regions, just set country code as region - if (plan.holidays_region !== selectedCountry) { - updatePlan({ holidays_region: selectedCountry }) - } - } - }).catch(() => setRegions([])).finally(() => setLoadingRegions(false)) - }, [selectedCountry, plan?.holidays_enabled]) - if (!plan) return null - const toggle = (key) => updatePlan({ [key]: !plan[key] }) - - const handleCountryChange = (countryCode) => { - updatePlan({ holidays_region: countryCode }) - } - - const handleRegionChange = (regionCode) => { - updatePlan({ holidays_region: regionCode }) - } + const toggle = (key: string) => updatePlan({ [key]: !plan[key] }) return (
@@ -136,21 +88,35 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) { /> {plan.holidays_enabled && (
- - {regions.length > 0 && ( - {t('vacay.noCalendars')}

+ )} + {(plan.holiday_calendars ?? []).map(cal => ( + updateHolidayCalendar(cal.id, data)} + onDelete={() => deleteHolidayCalendar(cal.id)} /> + ))} + {showAddForm ? ( + { await addHolidayCalendar(data); setShowAddForm(false) }} + onCancel={() => setShowAddForm(false)} + /> + ) : ( + )}
)} @@ -197,11 +163,11 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) { } interface SettingToggleProps { - icon: React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }> + icon: LucideIcon label: string hint: string value: boolean - onChange: (value: boolean) => void + onChange: () => void } function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingToggleProps) { @@ -223,3 +189,184 @@ function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingTogg
) } + +// ── shared region-loading helper ───────────────────────────────────────────── +async function fetchRegionOptions(country: string): Promise<{ value: string; label: string }[]> { + try { + const year = new Date().getFullYear() + const r = await apiClient.get(`/addons/vacay/holidays/${year}/${country}`) + const allCounties = new Set() + r.data.forEach(h => { if (h.counties) h.counties.forEach(c => allCounties.add(c)) }) + if (allCounties.size === 0) return [] + return [...allCounties].sort().map(c => { + let label = c.split('-')[1] || c + if (c.startsWith('DE-')) { + const m: Record = { BW:'Baden-Württemberg',BY:'Bayern',BE:'Berlin',BB:'Brandenburg',HB:'Bremen',HH:'Hamburg',HE:'Hessen',MV:'Mecklenburg-Vorpommern',NI:'Niedersachsen',NW:'Nordrhein-Westfalen',RP:'Rheinland-Pfalz',SL:'Saarland',SN:'Sachsen',ST:'Sachsen-Anhalt',SH:'Schleswig-Holstein',TH:'Thüringen' } + label = m[c.split('-')[1]] || label + } else if (c.startsWith('CH-')) { + const m: Record = { AG:'Aargau',AI:'Appenzell Innerrhoden',AR:'Appenzell Ausserrhoden',BE:'Bern',BL:'Basel-Landschaft',BS:'Basel-Stadt',FR:'Freiburg',GE:'Genf',GL:'Glarus',GR:'Graubünden',JU:'Jura',LU:'Luzern',NE:'Neuenburg',NW:'Nidwalden',OW:'Obwalden',SG:'St. Gallen',SH:'Schaffhausen',SO:'Solothurn',SZ:'Schwyz',TG:'Thurgau',TI:'Tessin',UR:'Uri',VD:'Waadt',VS:'Wallis',ZG:'Zug',ZH:'Zürich' } + label = m[c.split('-')[1]] || label + } + return { value: c, label } + }) + } catch { + return [] + } +} + +// ── Existing calendar row (inline edit) ────────────────────────────────────── +function CalendarRow({ cal, countries, onUpdate, onDelete }: { + cal: VacayHolidayCalendar + countries: { value: string; label: string }[] + language: string + onUpdate: (data: { region?: string; color?: string; label?: string | null }) => void + onDelete: () => void +}) { + const { t } = useTranslation() + const [localColor, setLocalColor] = useState(cal.color) + const [localLabel, setLocalLabel] = useState(cal.label || '') + const [regions, setRegions] = useState<{ value: string; label: string }[]>([]) + + const selectedCountry = cal.region.split('-')[0] + const selectedRegion = cal.region.includes('-') ? cal.region : '' + + useEffect(() => { setLocalColor(cal.color) }, [cal.color]) + useEffect(() => { setLocalLabel(cal.label || '') }, [cal.label]) + + useEffect(() => { + if (!selectedCountry) { setRegions([]); return } + fetchRegionOptions(selectedCountry).then(setRegions) + }, [selectedCountry]) + + return ( +
+ setLocalColor(e.target.value)} + onBlur={() => { if (localColor !== cal.color) onUpdate({ color: localColor }) }} + className="w-7 h-7 shrink-0 rounded cursor-pointer p-0" + style={{ border: 'none', background: 'transparent' }} + title={t('vacay.calendarColor')} + /> +
+ setLocalLabel(e.target.value)} + onBlur={() => { const v = localLabel.trim() || null; if (v !== cal.label) onUpdate({ label: v }) }} + placeholder={t('vacay.calendarLabel')} + className="w-full text-xs px-2 py-1 rounded" + style={{ background: 'var(--bg-card)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)' }} + /> + onUpdate({ region: v })} + options={countries} + placeholder={t('vacay.selectCountry')} + searchable + /> + {regions.length > 0 && ( + onUpdate({ region: v })} + options={regions} + placeholder={t('vacay.selectRegion')} + searchable + /> + )} +
+ +
+ ) +} + +// ── Add-new-calendar form ───────────────────────────────────────────────────── +function AddCalendarForm({ countries, onAdd, onCancel }: { + countries: { value: string; label: string }[] + language: string + onAdd: (data: { region: string; color: string; label: string | null }) => void + onCancel: () => void +}) { + const { t } = useTranslation() + const [region, setRegion] = useState('') + const [color, setColor] = useState('#fecaca') + const [label, setLabel] = useState('') + const [regions, setRegions] = useState<{ value: string; label: string }[]>([]) + const [loadingRegions, setLoadingRegions] = useState(false) + + const selectedCountry = region.split('-')[0] || '' + const selectedRegion = region.includes('-') ? region : '' + + useEffect(() => { + if (!selectedCountry) { setRegions([]); return } + setLoadingRegions(true) + fetchRegionOptions(selectedCountry).then(list => { setRegions(list) }).finally(() => setLoadingRegions(false)) + }, [selectedCountry]) + + const canAdd = selectedCountry && (regions.length === 0 || selectedRegion !== '') + + return ( +
+ setColor(e.target.value)} + className="w-7 h-7 shrink-0 rounded cursor-pointer p-0" + style={{ border: 'none', background: 'transparent' }} + title={t('vacay.calendarColor')} + /> +
+ setLabel(e.target.value)} + placeholder={t('vacay.calendarLabel')} + className="w-full text-xs px-2 py-1 rounded" + style={{ background: 'var(--bg-card)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)' }} + /> + { setRegion(v); setRegions([]) }} + options={countries} + placeholder={t('vacay.selectCountry')} + searchable + /> + {regions.length > 0 && ( + setRegion(v)} + options={regions} + placeholder={t('vacay.selectRegion')} + searchable + /> + )} +
+ + +
+
+
+ ) +} diff --git a/client/src/i18n/TranslationContext.tsx b/client/src/i18n/TranslationContext.tsx index ffa7bdb..9596988 100644 --- a/client/src/i18n/TranslationContext.tsx +++ b/client/src/i18n/TranslationContext.tsx @@ -3,7 +3,7 @@ import { useSettingsStore } from '../store/settingsStore' import de from './translations/de' import en from './translations/en' -type TranslationStrings = Record +type TranslationStrings = Record const translations: Record = { de, en } @@ -27,7 +27,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) { const fallback = translations.de function t(key: string, params?: Record): string { - let val: string = strings[key] ?? fallback[key] ?? key + let val: string = (strings[key] ?? fallback[key] ?? key) as string if (params) { Object.entries(params).forEach(([k, v]) => { val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v)) diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 0421e1f..eb63601 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -1,4 +1,4 @@ -const de: Record = { +const de: Record = { // Allgemein 'common.save': 'Speichern', 'common.cancel': 'Abbrechen', @@ -390,6 +390,10 @@ const de: Record = { 'vacay.publicHolidaysHint': 'Feiertage im Kalender markieren', 'vacay.selectCountry': 'Land wählen', 'vacay.selectRegion': 'Region wählen (optional)', + 'vacay.addCalendar': 'Kalender hinzufügen', + 'vacay.calendarLabel': 'Bezeichnung (optional)', + 'vacay.calendarColor': 'Farbe', + 'vacay.noCalendars': 'Noch keine Feiertagskalender angelegt', 'vacay.companyHolidays': 'Betriebsferien', 'vacay.companyHolidaysHint': 'Erlaubt das Markieren von unternehmensweiten Feiertagen', 'vacay.companyHolidaysNoDeduct': 'Betriebsferien werden nicht vom Urlaubskontingent abgezogen.', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 33c828a..e5e2f0d 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -1,4 +1,4 @@ -const en: Record = { +const en: Record = { // Common 'common.save': 'Save', 'common.cancel': 'Cancel', @@ -390,6 +390,10 @@ const en: Record = { 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', 'vacay.selectCountry': 'Select country', 'vacay.selectRegion': 'Select region (optional)', + 'vacay.addCalendar': 'Add calendar', + 'vacay.calendarLabel': 'Label (optional)', + 'vacay.calendarColor': 'Color', + 'vacay.noCalendars': 'No holiday calendars added yet', 'vacay.companyHolidays': 'Company Holidays', 'vacay.companyHolidaysHint': 'Allow marking company-wide holiday days', 'vacay.companyHolidaysNoDeduct': 'Company holidays do not count towards vacation days.', diff --git a/client/src/pages/VacayPage.tsx b/client/src/pages/VacayPage.tsx index 93dff4b..693e27d 100644 --- a/client/src/pages/VacayPage.tsx +++ b/client/src/pages/VacayPage.tsx @@ -104,7 +104,12 @@ export default function VacayPage(): React.ReactElement {
{t('vacay.legend')}
- {plan?.holidays_enabled && } + {plan?.holidays_enabled && (plan?.holiday_calendars ?? []).length === 0 && ( + + )} + {plan?.holidays_enabled && (plan?.holiday_calendars ?? []).map(cal => ( + + ))} {plan?.company_holidays_enabled && } {plan?.block_weekends && }
diff --git a/client/src/store/vacayStore.ts b/client/src/store/vacayStore.ts index c58e3a3..f1d7ef7 100644 --- a/client/src/store/vacayStore.ts +++ b/client/src/store/vacayStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand' import apiClient from '../api/client' import type { AxiosResponse } from 'axios' -import type { VacayPlan, VacayUser, VacayEntry, VacayStat, HolidaysMap, HolidayInfo } from '../types' +import type { VacayPlan, VacayUser, VacayEntry, VacayStat, HolidaysMap, HolidayInfo, VacayHolidayCalendar } from '../types' const ax = apiClient @@ -65,6 +65,9 @@ interface VacayApi { updateStats: (year: number, days: number, targetUserId?: number) => Promise getCountries: () => Promise<{ countries: string[] }> getHolidays: (year: number, country: string) => Promise + addHolidayCalendar: (data: { region: string; color?: string; label?: string | null }) => Promise<{ calendar: VacayHolidayCalendar }> + updateHolidayCalendar: (id: number, data: { region?: string; color?: string; label?: string | null }) => Promise<{ calendar: VacayHolidayCalendar }> + deleteHolidayCalendar: (id: number) => Promise } const api: VacayApi = { @@ -87,6 +90,9 @@ const api: VacayApi = { updateStats: (year, days, targetUserId) => ax.put(`/addons/vacay/stats/${year}`, { vacation_days: days, target_user_id: targetUserId }).then((r: AxiosResponse) => r.data), getCountries: () => ax.get('/addons/vacay/holidays/countries').then((r: AxiosResponse) => r.data), getHolidays: (year, country) => ax.get(`/addons/vacay/holidays/${year}/${country}`).then((r: AxiosResponse) => r.data), + addHolidayCalendar: (data) => ax.post('/addons/vacay/plan/holiday-calendars', data).then((r: AxiosResponse) => r.data), + updateHolidayCalendar: (id, data) => ax.put(`/addons/vacay/plan/holiday-calendars/${id}`, data).then((r: AxiosResponse) => r.data), + deleteHolidayCalendar: (id) => ax.delete(`/addons/vacay/plan/holiday-calendars/${id}`).then((r: AxiosResponse) => r.data), } interface VacayState { @@ -124,6 +130,9 @@ interface VacayState { loadStats: (year?: number) => Promise updateVacationDays: (year: number, days: number, targetUserId?: number) => Promise loadHolidays: (year?: number) => Promise + addHolidayCalendar: (data: { region: string; color?: string; label?: string | null }) => Promise + updateHolidayCalendar: (id: number, data: { region?: string; color?: string; label?: string | null }) => Promise + deleteHolidayCalendar: (id: number) => Promise loadAll: () => Promise } @@ -247,29 +256,47 @@ export const useVacayStore = create((set, get) => ({ loadHolidays: async (year?: number) => { const y = year || get().selectedYear const plan = get().plan - if (!plan?.holidays_enabled || !plan?.holidays_region) { + const calendars = plan?.holiday_calendars ?? [] + if (!plan?.holidays_enabled || calendars.length === 0) { set({ holidays: {} }) return } - const country = plan.holidays_region.split('-')[0] - const region = plan.holidays_region.includes('-') ? plan.holidays_region : null - try { - const data = await api.getHolidays(y, country) - const hasRegions = data.some((h: VacayHolidayRaw) => h.counties && h.counties.length > 0) - if (hasRegions && !region) { - set({ holidays: {} }) - return - } - const map: HolidaysMap = {} - data.forEach((h: VacayHolidayRaw) => { - if (h.global || !h.counties || (region && h.counties.includes(region))) { - map[h.date] = { name: h.name, localName: h.localName } - } - }) - set({ holidays: map }) - } catch { - set({ holidays: {} }) + const map: HolidaysMap = {} + for (const cal of calendars) { + const country = cal.region.split('-')[0] + const region = cal.region.includes('-') ? cal.region : null + try { + const data = await api.getHolidays(y, country) + const hasRegions = data.some((h: VacayHolidayRaw) => h.counties && h.counties.length > 0) + if (hasRegions && !region) continue + data.forEach((h: VacayHolidayRaw) => { + if (h.global || !h.counties || (region && h.counties.includes(region))) { + if (!map[h.date]) { + map[h.date] = { name: h.name, localName: h.localName, color: cal.color, label: cal.label } + } + } + }) + } catch { /* API error, skip */ } } + set({ holidays: map }) + }, + + addHolidayCalendar: async (data) => { + await api.addHolidayCalendar(data) + await get().loadPlan() + await get().loadHolidays() + }, + + updateHolidayCalendar: async (id, data) => { + await api.updateHolidayCalendar(id, data) + await get().loadPlan() + await get().loadHolidays() + }, + + deleteHolidayCalendar: async (id) => { + await api.deleteHolidayCalendar(id) + await get().loadPlan() + await get().loadHolidays() }, loadAll: async () => { diff --git a/client/src/types.ts b/client/src/types.ts index 761ae47..b0c7a32 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -281,10 +281,23 @@ export interface WebSocketEvent { } // Vacay types +export interface VacayHolidayCalendar { + id: number + plan_id: number + region: string + label: string | null + color: string + sort_order: number +} + export interface VacayPlan { id: number holidays_enabled: boolean holidays_region: string | null + holiday_calendars: VacayHolidayCalendar[] + block_weekends: boolean + carry_over_enabled: boolean + company_holidays_enabled: boolean name?: string year?: number owner_id?: number @@ -301,6 +314,9 @@ export interface VacayUser { export interface VacayEntry { date: string user_id: number + plan_id?: number + person_color?: string + person_name?: string } export interface VacayStat { @@ -312,6 +328,8 @@ export interface VacayStat { export interface HolidayInfo { name: string localName: string + color: string + label: string | null } export interface HolidaysMap { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 40f3d44..9670ecb 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -281,6 +281,15 @@ function createTables(db: Database.Database): void { UNIQUE(plan_id, date) ); + CREATE TABLE IF NOT EXISTS vacay_holiday_calendars ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, + region TEXT NOT NULL, + label TEXT, + color TEXT NOT NULL DEFAULT '#fecaca', + sort_order INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS day_accommodations ( id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, diff --git a/server/src/routes/vacay.ts b/server/src/routes/vacay.ts index 02b13d9..ba0cbe3 100644 --- a/server/src/routes/vacay.ts +++ b/server/src/routes/vacay.ts @@ -43,9 +43,59 @@ interface Holiday { counties?: string[] | null; } +interface VacayHolidayCalendar { + id: number; + plan_id: number; + region: string; + label: string | null; + color: string; + sort_order: number; +} + const holidayCache = new Map(); const CACHE_TTL = 24 * 60 * 60 * 1000; +async function applyHolidayCalendars(planId: number): Promise { + const plan = db.prepare('SELECT holidays_enabled FROM vacay_plans WHERE id = ?').get(planId) as { holidays_enabled: number } | undefined; + if (!plan?.holidays_enabled) return; + const calendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(planId) as VacayHolidayCalendar[]; + if (calendars.length === 0) return; + const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(planId) as { year: number }[]; + for (const cal of calendars) { + const country = cal.region.split('-')[0]; + const region = cal.region.includes('-') ? cal.region : null; + for (const { year } of years) { + try { + const cacheKey = `${year}-${country}`; + let holidays = holidayCache.get(cacheKey)?.data as Holiday[] | undefined; + if (!holidays) { + const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`); + holidays = await resp.json() as Holiday[]; + holidayCache.set(cacheKey, { data: holidays, time: Date.now() }); + } + const hasRegions = holidays.some((h: Holiday) => h.counties && h.counties.length > 0); + if (hasRegions && !region) continue; + for (const h of holidays) { + if (h.global || !h.counties || (region && h.counties.includes(region))) { + db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, h.date); + db.prepare('DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').run(planId, h.date); + } + } + } catch { /* API error, skip */ } + } + } +} + +async function migrateHolidayCalendars(planId: number, plan: VacayPlan): Promise { + const existing = db.prepare('SELECT id FROM vacay_holiday_calendars WHERE plan_id = ?').get(planId); + if (existing) return; + if (plan.holidays_enabled && plan.holidays_region) { + db.prepare( + 'INSERT INTO vacay_holiday_calendars (plan_id, region, label, color, sort_order) VALUES (?, ?, NULL, ?, 0)' + ).run(planId, plan.holidays_region, '#fecaca'); + } +} + const router = express.Router(); router.use(authenticate); @@ -124,6 +174,8 @@ router.get('/plan', (req: Request, res: Response) => { WHERE m.user_id = ? AND m.status = 'pending' `).all(authReq.user.id); + const holidayCalendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(activePlanId) as VacayHolidayCalendar[]; + res.json({ plan: { ...plan, @@ -131,6 +183,7 @@ router.get('/plan', (req: Request, res: Response) => { holidays_enabled: !!plan.holidays_enabled, company_holidays_enabled: !!plan.company_holidays_enabled, carry_over_enabled: !!plan.carry_over_enabled, + holiday_calendars: holidayCalendars, }, users, pendingInvites, @@ -166,30 +219,8 @@ router.put('/plan', async (req: Request, res: Response) => { } const updatedPlan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan; - if (updatedPlan.holidays_enabled && updatedPlan.holidays_region) { - const country = updatedPlan.holidays_region.split('-')[0]; - const region = updatedPlan.holidays_region.includes('-') ? updatedPlan.holidays_region : null; - const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(planId) as { year: number }[]; - for (const { year } of years) { - try { - const cacheKey = `${year}-${country}`; - let holidays = holidayCache.get(cacheKey)?.data as Holiday[] | undefined; - if (!holidays) { - const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`); - holidays = await resp.json() as Holiday[]; - holidayCache.set(cacheKey, { data: holidays, time: Date.now() }); - } - const hasRegions = (holidays as Holiday[]).some((h: Holiday) => h.counties && h.counties.length > 0); - if (hasRegions && !region) continue; - for (const h of holidays) { - if (h.global || !h.counties || (region && h.counties.includes(region))) { - db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, h.date); - db.prepare('DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').run(planId, h.date); - } - } - } catch { /* API error, skip */ } - } - } + await migrateHolidayCalendars(planId, updatedPlan); + await applyHolidayCalendars(planId); if (carry_over_enabled === false) { db.prepare('UPDATE vacay_user_years SET carried_over = 0 WHERE plan_id = ?').run(planId); @@ -217,11 +248,58 @@ router.put('/plan', async (req: Request, res: Response) => { notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings'); const updated = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan; + const updatedCalendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(planId) as VacayHolidayCalendar[]; res.json({ - plan: { ...updated, block_weekends: !!updated.block_weekends, holidays_enabled: !!updated.holidays_enabled, company_holidays_enabled: !!updated.company_holidays_enabled, carry_over_enabled: !!updated.carry_over_enabled } + plan: { ...updated, block_weekends: !!updated.block_weekends, holidays_enabled: !!updated.holidays_enabled, company_holidays_enabled: !!updated.company_holidays_enabled, carry_over_enabled: !!updated.carry_over_enabled, holiday_calendars: updatedCalendars } }); }); +router.post('/plan/holiday-calendars', (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { region, label, color, sort_order } = req.body; + if (!region) return res.status(400).json({ error: 'region required' }); + const planId = getActivePlanId(authReq.user.id); + const result = db.prepare( + 'INSERT INTO vacay_holiday_calendars (plan_id, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?)' + ).run(planId, region, label || null, color || '#fecaca', sort_order ?? 0); + const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(result.lastInsertRowid) as VacayHolidayCalendar; + notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings'); + res.json({ calendar: cal }); +}); + +router.put('/plan/holiday-calendars/:id', (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const id = parseInt(req.params.id); + const planId = getActivePlanId(authReq.user.id); + const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?').get(id, planId) as VacayHolidayCalendar | undefined; + if (!cal) return res.status(404).json({ error: 'Calendar not found' }); + const { region, label, color, sort_order } = req.body; + const updates: string[] = []; + const params: (string | number | null)[] = []; + if (region !== undefined) { updates.push('region = ?'); params.push(region); } + if (label !== undefined) { updates.push('label = ?'); params.push(label); } + if (color !== undefined) { updates.push('color = ?'); params.push(color); } + if (sort_order !== undefined) { updates.push('sort_order = ?'); params.push(sort_order); } + if (updates.length > 0) { + params.push(id); + db.prepare(`UPDATE vacay_holiday_calendars SET ${updates.join(', ')} WHERE id = ?`).run(...params); + } + const updated = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(id) as VacayHolidayCalendar; + notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings'); + res.json({ calendar: updated }); +}); + +router.delete('/plan/holiday-calendars/:id', (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const id = parseInt(req.params.id); + const planId = getActivePlanId(authReq.user.id); + const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?').get(id, planId); + if (!cal) return res.status(404).json({ error: 'Calendar not found' }); + db.prepare('DELETE FROM vacay_holiday_calendars WHERE id = ?').run(id); + notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings'); + res.json({ success: true }); +}); + router.put('/color', (req: Request, res: Response) => { const authReq = req as AuthRequest; const { color, target_user_id } = req.body; From 83d256ebacba5df012d007f94153d752360f5623 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 28 Mar 2026 23:23:52 +0100 Subject: [PATCH 05/13] =?UTF-8?q?feat:=20custom=20timezones=20in=20timezon?= =?UTF-8?q?e=20widget=20=E2=80=94=20closes=20#21?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/Dashboard/TimezoneWidget.tsx | 41 ++++++++++++++++++- client/src/i18n/translations/de.ts | 7 ++++ client/src/i18n/translations/en.ts | 7 ++++ client/src/i18n/translations/es.ts | 7 ++++ 4 files changed, 61 insertions(+), 1 deletion(-) diff --git a/client/src/components/Dashboard/TimezoneWidget.tsx b/client/src/components/Dashboard/TimezoneWidget.tsx index 5ee97e5..087937a 100644 --- a/client/src/components/Dashboard/TimezoneWidget.tsx +++ b/client/src/components/Dashboard/TimezoneWidget.tsx @@ -51,6 +51,9 @@ export default function TimezoneWidget() { }) const [now, setNow] = useState(Date.now()) const [showAdd, setShowAdd] = useState(false) + const [customLabel, setCustomLabel] = useState('') + const [customTz, setCustomTz] = useState('') + const [customError, setCustomError] = useState('') useEffect(() => { const i = setInterval(() => setNow(Date.now()), 10000) @@ -61,6 +64,20 @@ export default function TimezoneWidget() { localStorage.setItem('dashboard_timezones', JSON.stringify(zones)) }, [zones]) + const isValidTz = (tz: string) => { + try { Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date()); return true } catch { return false } + } + + const addCustomZone = () => { + const tz = customTz.trim() + if (!tz) { setCustomError(t('dashboard.timezoneCustomErrorEmpty')); return } + if (!isValidTz(tz)) { setCustomError(t('dashboard.timezoneCustomErrorInvalid')); return } + if (zones.find(z => z.tz === tz)) { setCustomError(t('dashboard.timezoneCustomErrorDuplicate')); return } + const label = customLabel.trim() || tz.split('/').pop()?.replace(/_/g, ' ') || tz + setZones([...zones, { label, tz }]) + setCustomLabel(''); setCustomTz(''); setCustomError(''); setShowAdd(false) + } + const addZone = (zone) => { if (!zones.find(z => z.tz === zone.tz)) { setZones([...zones, zone]) @@ -108,7 +125,29 @@ export default function TimezoneWidget() { {/* Add zone dropdown */} {showAdd && ( -
+
+ {/* Custom timezone */} +
+

{t('dashboard.timezoneCustomTitle')}

+
+ setCustomLabel(e.target.value)} + placeholder={t('dashboard.timezoneCustomLabelPlaceholder')} + className="w-full px-2 py-1.5 rounded-lg text-xs outline-none" + style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border-secondary)' }} /> + { setCustomTz(e.target.value); setCustomError('') }} + placeholder={t('dashboard.timezoneCustomTzPlaceholder')} + className="w-full px-2 py-1.5 rounded-lg text-xs outline-none" + style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: `1px solid ${customError ? '#ef4444' : 'var(--border-secondary)'}` }} + onKeyDown={e => { if (e.key === 'Enter') addCustomZone() }} /> + {customError &&

{customError}

} + +
+
+ {/* Popular zones */} {POPULAR_ZONES.filter(z => !zones.find(existing => existing.tz === z.tz)).map(z => (
+ )} +
setLocalLabel(e.target.value)} onBlur={() => { const v = localLabel.trim() || null; if (v !== cal.label) onUpdate({ label: v }) }} + onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }} placeholder={t('vacay.calendarLabel')} - className="w-full text-xs px-2 py-1 rounded" - style={{ background: 'var(--bg-card)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)' }} + style={{ width: '100%', fontSize: 12, padding: '6px 10px', borderRadius: 8, background: 'var(--bg-input)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} /> - setColor(e.target.value)} - className="w-7 h-7 shrink-0 rounded cursor-pointer p-0" - style={{ border: 'none', background: 'transparent' }} - title={t('vacay.calendarColor')} - /> +
+
+
+ )} +
setLabel(e.target.value)} placeholder={t('vacay.calendarLabel')} - className="w-full text-xs px-2 py-1 rounded" - style={{ background: 'var(--bg-card)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)' }} + style={{ width: '100%', fontSize: 12, padding: '6px 10px', borderRadius: 8, background: 'var(--bg-input)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} />