mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Merge pull request #707 from mauriceboe/fix/journey-page-bugs
fix(journey): fix issue #704 — active logic, archive, places rename, search & trip reminders
This commit is contained in:
@@ -1570,6 +1570,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.confirmShareTitle': 'مشاركة مع أعضاء الرحلة؟',
|
||||
'memories.confirmShareHint': '{count} صور ستكون مرئية لجميع أعضاء هذه الرحلة. يمكنك جعل الصور الفردية خاصة لاحقًا.',
|
||||
'memories.confirmShareButton': 'مشاركة الصور',
|
||||
'journey.search.placeholder': 'البحث في الرحلات…',
|
||||
'journey.search.noResults': 'لا توجد رحلات تطابق "{query}"',
|
||||
'journey.status.archived': 'مؤرشف',
|
||||
'journey.settings.endJourney': 'أرشفة الرحلة',
|
||||
'journey.settings.reopenJourney': 'استعادة الرحلة',
|
||||
'journey.settings.archived': 'تم أرشفة الرحلة',
|
||||
'journey.settings.reopened': 'تمت إعادة فتح الرحلة',
|
||||
'journey.settings.endDescription': 'يخفي شارة البث المباشر. يمكنك إعادة الفتح في أي وقت.',
|
||||
'journey.settings.failedToDelete': 'فشل في الحذف',
|
||||
'journey.entries.deleteTitle': 'حذف الإدخال',
|
||||
'journey.photosUploaded': 'تم رفع {count} صورة',
|
||||
@@ -1876,6 +1884,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'فشل إرسال Ntfy التجريبي',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'يُرسل Ntfy للمسؤول دائمًا عند تهيئة موضوع',
|
||||
'admin.notifications.adminNotificationsHint': 'حدد القنوات التي تُسلّم إشعارات المسؤول (مثل تنبيهات الإصدارات). يُرسل الـ Webhook تلقائيًا عند تعيين رابط URL لـ Webhook المسؤول.',
|
||||
'admin.notifications.tripReminders.title': 'تذكيرات الرحلات',
|
||||
'admin.notifications.tripReminders.hint': 'إرسال تذكير قبل بدء الرحلة (يتطلب تعيين أيام التذكير على الرحلة).',
|
||||
'admin.notifications.tripReminders.enabled': 'تم تفعيل تذكيرات الرحلات',
|
||||
'admin.notifications.tripReminders.disabled': 'تم تعطيل تذكيرات الرحلات',
|
||||
'admin.tabs.notifications': 'الإشعارات',
|
||||
'notifications.versionAvailable.title': 'تحديث متاح',
|
||||
'notifications.versionAvailable.text': 'TREK {version} متاح الآن.',
|
||||
|
||||
@@ -1825,6 +1825,10 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Falha ao enviar Ntfy de teste',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'O Ntfy de admin sempre dispara quando um tópico está configurado',
|
||||
'admin.notifications.adminNotificationsHint': 'Configure quais canais entregam notificações de admin (ex. alertas de versão). O webhook dispara automaticamente se uma URL de webhook de admin estiver definida.',
|
||||
'admin.notifications.tripReminders.title': 'Lembretes de viagem',
|
||||
'admin.notifications.tripReminders.hint': 'Envia uma notificação de lembrete antes do início de uma viagem (requer dias de lembrete definidos na viagem).',
|
||||
'admin.notifications.tripReminders.enabled': 'Lembretes de viagem ativados',
|
||||
'admin.notifications.tripReminders.disabled': 'Lembretes de viagem desativados',
|
||||
'admin.tabs.notifications': 'Notificações',
|
||||
'notifications.versionAvailable.title': 'Atualização disponível',
|
||||
'notifications.versionAvailable.text': 'TREK {version} já está disponível.',
|
||||
@@ -1872,6 +1876,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.saveRouteNotConfigured': 'A rota de salvamento não está configurada para este provedor',
|
||||
'memories.testRouteNotConfigured': 'A rota de teste não está configurada para este provedor',
|
||||
'memories.fillRequiredFields': 'Por favor preencha todos os campos obrigatórios',
|
||||
'journey.search.placeholder': 'Buscar jornadas…',
|
||||
'journey.search.noResults': 'Nenhuma jornada corresponde a "{query}"',
|
||||
'journey.title': 'Jornada',
|
||||
'journey.subtitle': 'Registre suas viagens em tempo real',
|
||||
'journey.new': 'Nova jornada',
|
||||
@@ -1893,6 +1899,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.status.active': 'Ativa',
|
||||
'journey.status.completed': 'Concluída',
|
||||
'journey.status.upcoming': 'Próxima',
|
||||
'journey.status.archived': 'Arquivado',
|
||||
'journey.checkin.add': 'Fazer check-in',
|
||||
'journey.checkin.namePlaceholder': 'Nome do local',
|
||||
'journey.checkin.notesPlaceholder': 'Notas (opcional)',
|
||||
@@ -2046,6 +2053,11 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.name': 'Nome',
|
||||
'journey.settings.subtitle': 'Subtítulo',
|
||||
'journey.settings.subtitlePlaceholder': 'ex. Tailândia, Vietnã e Camboja',
|
||||
'journey.settings.endJourney': 'Arquivar Jornada',
|
||||
'journey.settings.reopenJourney': 'Restaurar Jornada',
|
||||
'journey.settings.archived': 'Jornada arquivada',
|
||||
'journey.settings.reopened': 'Jornada reaberta',
|
||||
'journey.settings.endDescription': 'Oculta o selo Ao Vivo. Você pode reabrir a qualquer momento.',
|
||||
'journey.settings.delete': 'Excluir',
|
||||
'journey.settings.deleteJourney': 'Excluir jornada',
|
||||
'journey.settings.deleteMessage': 'Excluir "{title}"? Todas as entradas e fotos serão perdidas.',
|
||||
|
||||
@@ -1830,6 +1830,10 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Testovací Ntfy selhalo',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy odesílá vždy, když je nakonfigurováno téma',
|
||||
'admin.notifications.adminNotificationsHint': 'Nastavte, které kanály doručují admin oznámení (např. upozornění na verze). Webhook odesílá automaticky, pokud je nastavena URL admin webhooku.',
|
||||
'admin.notifications.tripReminders.title': 'Připomínky výletů',
|
||||
'admin.notifications.tripReminders.hint': 'Odešle upozornění před začátkem výletu (vyžaduje nastavené dny připomínky na výletu).',
|
||||
'admin.notifications.tripReminders.enabled': 'Připomínky výletů aktivovány',
|
||||
'admin.notifications.tripReminders.disabled': 'Připomínky výletů deaktivovány',
|
||||
'admin.tabs.notifications': 'Oznámení',
|
||||
'notifications.versionAvailable.title': 'Dostupná aktualizace',
|
||||
'notifications.versionAvailable.text': 'TREK {version} je nyní k dispozici.',
|
||||
@@ -1877,6 +1881,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.saveRouteNotConfigured': 'Trasa uložení není nakonfigurována pro tohoto poskytovatele',
|
||||
'memories.testRouteNotConfigured': 'Testovací trasa není nakonfigurována pro tohoto poskytovatele',
|
||||
'memories.fillRequiredFields': 'Prosím vyplňte všechna povinná pole',
|
||||
'journey.search.placeholder': 'Hledat cesty…',
|
||||
'journey.search.noResults': 'Žádné cesty neodpovídají „{query}"',
|
||||
'journey.title': 'Cestovní deník',
|
||||
'journey.subtitle': 'Zaznamenávejte své cesty průběžně',
|
||||
'journey.new': 'Nový cestovní deník',
|
||||
@@ -1898,6 +1904,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.status.active': 'Aktivní',
|
||||
'journey.status.completed': 'Dokončeno',
|
||||
'journey.status.upcoming': 'Nadcházející',
|
||||
'journey.status.archived': 'Archivováno',
|
||||
'journey.checkin.add': 'Odbavit se',
|
||||
'journey.checkin.namePlaceholder': 'Název místa',
|
||||
'journey.checkin.notesPlaceholder': 'Poznámky (volitelné)',
|
||||
@@ -2051,6 +2058,11 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.name': 'Název',
|
||||
'journey.settings.subtitle': 'Podtitul',
|
||||
'journey.settings.subtitlePlaceholder': 'např. Thajsko, Vietnam a Kambodža',
|
||||
'journey.settings.endJourney': 'Archivovat cestu',
|
||||
'journey.settings.reopenJourney': 'Obnovit cestu',
|
||||
'journey.settings.archived': 'Cesta archivována',
|
||||
'journey.settings.reopened': 'Cesta znovu otevřena',
|
||||
'journey.settings.endDescription': 'Skryje odznak Živě. Kdykoli jej lze znovu otevřít.',
|
||||
'journey.settings.delete': 'Smazat',
|
||||
'journey.settings.deleteJourney': 'Smazat cestovní deník',
|
||||
'journey.settings.deleteMessage': 'Smazat „{title}"? Všechny záznamy a fotky budou ztraceny.',
|
||||
|
||||
@@ -1833,6 +1833,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Test-Ntfy fehlgeschlagen',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin-Ntfy sendet immer, wenn ein Thema konfiguriert ist',
|
||||
'admin.notifications.adminNotificationsHint': 'Konfiguriere, welche Kanäle Admin-Benachrichtigungen liefern (z. B. Versions-Updates). Der Webhook sendet automatisch, wenn eine Admin-Webhook-URL gesetzt ist.',
|
||||
'admin.notifications.tripReminders.title': 'Reiseerinnerungen',
|
||||
'admin.notifications.tripReminders.hint': 'Sendet eine Erinnerungsbenachrichtigung vor Reisebeginn (erfordert gesetzte Erinnerungstage bei der Reise).',
|
||||
'admin.notifications.tripReminders.enabled': 'Reiseerinnerungen aktiviert',
|
||||
'admin.notifications.tripReminders.disabled': 'Reiseerinnerungen deaktiviert',
|
||||
'admin.tabs.notifications': 'Benachrichtigungen',
|
||||
'notifications.versionAvailable.title': 'Update verfügbar',
|
||||
'notifications.versionAvailable.text': 'TREK {version} ist jetzt verfügbar.',
|
||||
@@ -1874,6 +1878,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notif.dev.unknown_event.text': 'Ereignistyp "{event}" ist nicht in EVENT_NOTIFICATION_CONFIG registriert',
|
||||
|
||||
// Journey Addon
|
||||
'journey.search.placeholder': 'Reisen suchen…',
|
||||
'journey.search.noResults': 'Keine Reisen passen zu „{query}"',
|
||||
'journey.title': 'Journey',
|
||||
'journey.subtitle': 'Dokumentiere deine Reisen unterwegs',
|
||||
'journey.new': 'Neue Journey',
|
||||
@@ -1895,6 +1901,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.status.active': 'Aktiv',
|
||||
'journey.status.completed': 'Abgeschlossen',
|
||||
'journey.status.upcoming': 'Anstehend',
|
||||
'journey.status.archived': 'Archiviert',
|
||||
'journey.checkin.add': 'Einchecken',
|
||||
'journey.checkin.namePlaceholder': 'Ortsname',
|
||||
'journey.checkin.notesPlaceholder': 'Notizen (optional)',
|
||||
@@ -2052,6 +2059,11 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.name': 'Name',
|
||||
'journey.settings.subtitle': 'Untertitel',
|
||||
'journey.settings.subtitlePlaceholder': 'z.B. Thailand, Vietnam & Kambodscha',
|
||||
'journey.settings.endJourney': 'Reise archivieren',
|
||||
'journey.settings.reopenJourney': 'Reise wiederherstellen',
|
||||
'journey.settings.archived': 'Reise archiviert',
|
||||
'journey.settings.reopened': 'Reise erneut geöffnet',
|
||||
'journey.settings.endDescription': 'Blendet das Live-Abzeichen aus. Sie können jederzeit wieder öffnen.',
|
||||
'journey.settings.delete': 'Löschen',
|
||||
'journey.settings.deleteJourney': 'Journey löschen',
|
||||
'journey.settings.deleteMessage': '"{title}" löschen? Alle Einträge und Fotos gehen verloren.',
|
||||
|
||||
@@ -251,6 +251,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Test ntfy failed',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy always fires when a topic is configured',
|
||||
'admin.notifications.adminNotificationsHint': 'Configure which channels deliver admin-only notifications (e.g. version alerts).',
|
||||
'admin.notifications.tripReminders.title': 'Trip Reminders',
|
||||
'admin.notifications.tripReminders.hint': 'Send a reminder notification before a trip starts (requires reminder days to be set on the trip).',
|
||||
'admin.notifications.tripReminders.enabled': 'Trip reminders enabled',
|
||||
'admin.notifications.tripReminders.disabled': 'Trip reminders disabled',
|
||||
'admin.smtp.title': 'Email & Notifications',
|
||||
'admin.smtp.hint': 'SMTP configuration for sending email notifications.',
|
||||
'admin.smtp.testButton': 'Send test email',
|
||||
@@ -1877,6 +1881,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notif.dev.unknown_event.text': 'Event type "{event}" is not registered in EVENT_NOTIFICATION_CONFIG',
|
||||
|
||||
// Journey addon
|
||||
'journey.search.placeholder': 'Search journeys…',
|
||||
'journey.search.noResults': 'No journeys match "{query}"',
|
||||
'journey.title': 'Journey',
|
||||
'journey.subtitle': 'Track your travels as they happen',
|
||||
'journey.new': 'New Journey',
|
||||
@@ -1898,6 +1904,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.status.active': 'Active',
|
||||
'journey.status.completed': 'Completed',
|
||||
'journey.status.upcoming': 'Upcoming',
|
||||
'journey.status.archived': 'Archived',
|
||||
'journey.checkin.add': 'Check in',
|
||||
'journey.checkin.namePlaceholder': 'Location name',
|
||||
'journey.checkin.notesPlaceholder': 'Notes (optional)',
|
||||
@@ -2076,6 +2083,11 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.name': 'Name',
|
||||
'journey.settings.subtitle': 'Subtitle',
|
||||
'journey.settings.subtitlePlaceholder': 'e.g. Thailand, Vietnam & Cambodia',
|
||||
'journey.settings.endJourney': 'Archive Journey',
|
||||
'journey.settings.reopenJourney': 'Restore Journey',
|
||||
'journey.settings.archived': 'Journey archived',
|
||||
'journey.settings.reopened': 'Journey reopened',
|
||||
'journey.settings.endDescription': 'Hides the Live badge. You can reopen anytime.',
|
||||
'journey.settings.delete': 'Delete',
|
||||
'journey.settings.deleteJourney': 'Delete Journey',
|
||||
'journey.settings.deleteMessage': 'Delete "{title}"? All entries and photos will be lost.',
|
||||
|
||||
@@ -1835,6 +1835,10 @@ const es: Record<string, string> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Error al enviar el Ntfy de prueba',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'El Ntfy de admin siempre se activa cuando hay un tema configurado',
|
||||
'admin.notifications.adminNotificationsHint': 'Configura qué canales entregan notificaciones de admin (ej. alertas de versión). El webhook se activa automáticamente si hay una URL de webhook de admin configurada.',
|
||||
'admin.notifications.tripReminders.title': 'Recordatorios de viaje',
|
||||
'admin.notifications.tripReminders.hint': 'Envía una notificación de recordatorio antes de que comience un viaje (requiere días de recordatorio configurados en el viaje).',
|
||||
'admin.notifications.tripReminders.enabled': 'Recordatorios de viaje activados',
|
||||
'admin.notifications.tripReminders.disabled': 'Recordatorios de viaje desactivados',
|
||||
'admin.tabs.notifications': 'Notificaciones',
|
||||
'notifications.versionAvailable.title': 'Actualización disponible',
|
||||
'notifications.versionAvailable.text': 'TREK {version} ya está disponible.',
|
||||
@@ -1879,6 +1883,8 @@ const es: Record<string, string> = {
|
||||
'common.justNow': 'justo ahora',
|
||||
'common.hoursAgo': 'hace {count}h',
|
||||
'common.daysAgo': 'hace {count}d',
|
||||
'journey.search.placeholder': 'Buscar viajes…',
|
||||
'journey.search.noResults': 'Ningún viaje coincide con "{query}"',
|
||||
'journey.title': 'Travesía',
|
||||
'journey.subtitle': 'Registra tus viajes en tiempo real',
|
||||
'journey.new': 'Nueva travesía',
|
||||
@@ -1900,6 +1906,7 @@ const es: Record<string, string> = {
|
||||
'journey.status.active': 'Activa',
|
||||
'journey.status.completed': 'Completada',
|
||||
'journey.status.upcoming': 'Próxima',
|
||||
'journey.status.archived': 'Archivado',
|
||||
'journey.checkin.add': 'Registrar ubicación',
|
||||
'journey.checkin.namePlaceholder': 'Nombre del lugar',
|
||||
'journey.checkin.notesPlaceholder': 'Notas (opcional)',
|
||||
@@ -2053,6 +2060,11 @@ const es: Record<string, string> = {
|
||||
'journey.settings.name': 'Nombre',
|
||||
'journey.settings.subtitle': 'Subtítulo',
|
||||
'journey.settings.subtitlePlaceholder': 'p. ej. Tailandia, Vietnam y Camboya',
|
||||
'journey.settings.endJourney': 'Archivar viaje',
|
||||
'journey.settings.reopenJourney': 'Restaurar viaje',
|
||||
'journey.settings.archived': 'Viaje archivado',
|
||||
'journey.settings.reopened': 'Viaje reabierto',
|
||||
'journey.settings.endDescription': 'Oculta la insignia En Vivo. Puedes reabrirlo en cualquier momento.',
|
||||
'journey.settings.delete': 'Eliminar',
|
||||
'journey.settings.deleteJourney': 'Eliminar travesía',
|
||||
'journey.settings.deleteMessage': '¿Eliminar "{title}"? Todas las entradas y fotos se perderán.',
|
||||
|
||||
@@ -1829,6 +1829,10 @@ const fr: Record<string, string> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Échec de l\'envoi du Ntfy de test',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Le Ntfy admin s\'active toujours lorsqu\'un sujet est configuré',
|
||||
'admin.notifications.adminNotificationsHint': 'Configurez quels canaux envoient les notifications admin (ex. alertes de version). Le webhook s\'active automatiquement si une URL webhook admin est définie.',
|
||||
'admin.notifications.tripReminders.title': 'Rappels de voyage',
|
||||
'admin.notifications.tripReminders.hint': 'Envoie une notification de rappel avant le début d\'un voyage (nécessite des jours de rappel définis sur le voyage).',
|
||||
'admin.notifications.tripReminders.enabled': 'Rappels de voyage activés',
|
||||
'admin.notifications.tripReminders.disabled': 'Rappels de voyage désactivés',
|
||||
'admin.tabs.notifications': 'Notifications',
|
||||
'notifications.versionAvailable.title': 'Mise à jour disponible',
|
||||
'notifications.versionAvailable.text': 'TREK {version} est maintenant disponible.',
|
||||
@@ -1873,6 +1877,8 @@ const fr: Record<string, string> = {
|
||||
'common.justNow': 'à l\'instant',
|
||||
'common.hoursAgo': 'il y a {count}h',
|
||||
'common.daysAgo': 'il y a {count}j',
|
||||
'journey.search.placeholder': 'Rechercher des journaux…',
|
||||
'journey.search.noResults': 'Aucun journal ne correspond à « {query} »',
|
||||
'journey.title': 'Journal de voyage',
|
||||
'journey.subtitle': 'Suivez vos voyages en temps réel',
|
||||
'journey.new': 'Nouveau journal',
|
||||
@@ -1894,6 +1900,7 @@ const fr: Record<string, string> = {
|
||||
'journey.status.active': 'Actif',
|
||||
'journey.status.completed': 'Terminé',
|
||||
'journey.status.upcoming': 'À venir',
|
||||
'journey.status.archived': 'Archivé',
|
||||
'journey.checkin.add': 'Check-in',
|
||||
'journey.checkin.namePlaceholder': 'Nom du lieu',
|
||||
'journey.checkin.notesPlaceholder': 'Notes (facultatif)',
|
||||
@@ -2047,6 +2054,11 @@ const fr: Record<string, string> = {
|
||||
'journey.settings.name': 'Nom',
|
||||
'journey.settings.subtitle': 'Sous-titre',
|
||||
'journey.settings.subtitlePlaceholder': 'ex. Thaïlande, Vietnam et Cambodge',
|
||||
'journey.settings.endJourney': 'Archiver le journal',
|
||||
'journey.settings.reopenJourney': 'Restaurer le journal',
|
||||
'journey.settings.archived': 'Journal archivé',
|
||||
'journey.settings.reopened': 'Journal rouvert',
|
||||
'journey.settings.endDescription': 'Masque l\'indicateur En direct. Vous pouvez rouvrir à tout moment.',
|
||||
'journey.settings.delete': 'Supprimer',
|
||||
'journey.settings.deleteJourney': 'Supprimer le journal',
|
||||
'journey.settings.deleteMessage': 'Supprimer « {title} » ? Toutes les entrées et photos seront perdues.',
|
||||
|
||||
@@ -1827,6 +1827,10 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Teszt Ntfy sikertelen',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Az admin Ntfy mindig küld, ha egy téma konfigurálva van',
|
||||
'admin.notifications.adminNotificationsHint': 'Állítsa be, hogy mely csatornák szállítsák az admin értesítéseket (pl. verziófrissítési figyelmeztetések). A webhook automatikusan küld, ha admin webhook URL van megadva.',
|
||||
'admin.notifications.tripReminders.title': 'Utazási emlékeztetők',
|
||||
'admin.notifications.tripReminders.hint': 'Emlékeztető értesítést küld az utazás kezdete előtt (az utazásnál megadott emlékeztető napok szükségesek).',
|
||||
'admin.notifications.tripReminders.enabled': 'Utazási emlékeztetők engedélyezve',
|
||||
'admin.notifications.tripReminders.disabled': 'Utazási emlékeztetők letiltva',
|
||||
'admin.tabs.notifications': 'Értesítések',
|
||||
'notifications.versionAvailable.title': 'Elérhető frissítés',
|
||||
'notifications.versionAvailable.text': 'A TREK {version} már elérhető.',
|
||||
@@ -1874,6 +1878,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.saveRouteNotConfigured': 'A mentési útvonal nincs konfigurálva ehhez a szolgáltatóhoz',
|
||||
'memories.testRouteNotConfigured': 'A tesztútvonal nincs konfigurálva ehhez a szolgáltatóhoz',
|
||||
'memories.fillRequiredFields': 'Kérjük töltse ki az összes kötelező mezőt',
|
||||
'journey.search.placeholder': 'Utak keresése…',
|
||||
'journey.search.noResults': 'Nincs „{query}" kifejezéssel egyező út',
|
||||
'journey.title': 'Útinaplók',
|
||||
'journey.subtitle': 'Kövesse nyomon utazásait valós időben',
|
||||
'journey.new': 'Új útinapló',
|
||||
@@ -1895,6 +1901,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.status.active': 'Aktív',
|
||||
'journey.status.completed': 'Befejezett',
|
||||
'journey.status.upcoming': 'Közelgő',
|
||||
'journey.status.archived': 'Archivált',
|
||||
'journey.checkin.add': 'Bejelentkezés',
|
||||
'journey.checkin.namePlaceholder': 'Helyszín neve',
|
||||
'journey.checkin.notesPlaceholder': 'Jegyzetek (opcionális)',
|
||||
@@ -2048,6 +2055,11 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.name': 'Név',
|
||||
'journey.settings.subtitle': 'Alcím',
|
||||
'journey.settings.subtitlePlaceholder': 'pl. Thaiföld, Vietnam és Kambodzsa',
|
||||
'journey.settings.endJourney': 'Út archiválása',
|
||||
'journey.settings.reopenJourney': 'Út visszaállítása',
|
||||
'journey.settings.archived': 'Út archiválva',
|
||||
'journey.settings.reopened': 'Út újranyitva',
|
||||
'journey.settings.endDescription': 'Elrejti az Élő jelzést. Bármikor újranyitható.',
|
||||
'journey.settings.delete': 'Törlés',
|
||||
'journey.settings.deleteJourney': 'Útinapló törlése',
|
||||
'journey.settings.deleteMessage': 'Törlöd a(z) „{title}" útinaplót? Minden bejegyzés és fotó elveszik.',
|
||||
|
||||
@@ -251,6 +251,10 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Uji Ntfy gagal',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy selalu berjalan jika topik dikonfigurasi',
|
||||
'admin.notifications.adminNotificationsHint': 'Atur saluran mana yang mengirimkan notifikasi khusus admin (mis. peringatan versi).',
|
||||
'admin.notifications.tripReminders.title': 'Pengingat Perjalanan',
|
||||
'admin.notifications.tripReminders.hint': 'Mengirim notifikasi pengingat sebelum perjalanan dimulai (memerlukan hari pengingat yang diatur pada perjalanan).',
|
||||
'admin.notifications.tripReminders.enabled': 'Pengingat perjalanan diaktifkan',
|
||||
'admin.notifications.tripReminders.disabled': 'Pengingat perjalanan dinonaktifkan',
|
||||
'admin.smtp.title': 'Email & Notifikasi',
|
||||
'admin.smtp.hint': 'Konfigurasi SMTP untuk pengiriman notifikasi email.',
|
||||
'admin.smtp.testButton': 'Kirim email uji',
|
||||
@@ -1877,6 +1881,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notif.dev.unknown_event.text': 'Tipe event "{event}" tidak terdaftar di EVENT_NOTIFICATION_CONFIG',
|
||||
|
||||
// Journey addon
|
||||
'journey.search.placeholder': 'Cari perjalanan…',
|
||||
'journey.search.noResults': 'Tidak ada perjalanan yang cocok dengan "{query}"',
|
||||
'journey.title': 'Journey',
|
||||
'journey.subtitle': 'Lacak perjalananmu saat terjadi',
|
||||
'journey.new': 'Journey Baru',
|
||||
@@ -1898,6 +1904,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.status.active': 'Aktif',
|
||||
'journey.status.completed': 'Selesai',
|
||||
'journey.status.upcoming': 'Mendatang',
|
||||
'journey.status.archived': 'Diarsipkan',
|
||||
'journey.checkin.add': 'Check in',
|
||||
'journey.checkin.namePlaceholder': 'Nama lokasi',
|
||||
'journey.checkin.notesPlaceholder': 'Catatan (opsional)',
|
||||
@@ -2075,6 +2082,11 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.name': 'Nama',
|
||||
'journey.settings.subtitle': 'Subjudul',
|
||||
'journey.settings.subtitlePlaceholder': 'mis. Thailand, Vietnam & Kamboja',
|
||||
'journey.settings.endJourney': 'Arsipkan Perjalanan',
|
||||
'journey.settings.reopenJourney': 'Pulihkan Perjalanan',
|
||||
'journey.settings.archived': 'Perjalanan diarsipkan',
|
||||
'journey.settings.reopened': 'Perjalanan dibuka kembali',
|
||||
'journey.settings.endDescription': 'Menyembunyikan lencana Langsung. Anda dapat membuka kembali kapan saja.',
|
||||
'journey.settings.delete': 'Hapus',
|
||||
'journey.settings.deleteJourney': 'Hapus Journey',
|
||||
'journey.settings.deleteMessage': 'Hapus "{title}"? Semua entri dan foto akan hilang.',
|
||||
|
||||
@@ -1830,6 +1830,10 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Invio Ntfy di test fallito',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Il Ntfy admin si attiva sempre quando un argomento è configurato',
|
||||
'admin.notifications.adminNotificationsHint': 'Configura quali canali consegnano le notifiche admin (es. avvisi di versione). Il webhook si attiva automaticamente se è impostato un URL webhook admin.',
|
||||
'admin.notifications.tripReminders.title': 'Promemoria viaggio',
|
||||
'admin.notifications.tripReminders.hint': 'Invia una notifica promemoria prima dell\'inizio di un viaggio (richiede giorni di promemoria impostati sul viaggio).',
|
||||
'admin.notifications.tripReminders.enabled': 'Promemoria viaggio attivati',
|
||||
'admin.notifications.tripReminders.disabled': 'Promemoria viaggio disattivati',
|
||||
'admin.tabs.notifications': 'Notifiche',
|
||||
'notifications.versionAvailable.title': 'Aggiornamento disponibile',
|
||||
'notifications.versionAvailable.text': 'TREK {version} è ora disponibile.',
|
||||
@@ -1874,6 +1878,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.justNow': 'proprio ora',
|
||||
'common.hoursAgo': '{count}h fa',
|
||||
'common.daysAgo': '{count}g fa',
|
||||
'journey.search.placeholder': 'Cerca viaggi…',
|
||||
'journey.search.noResults': 'Nessun viaggio corrisponde a "{query}"',
|
||||
'journey.title': 'Diario di viaggio',
|
||||
'journey.subtitle': 'Segui i tuoi viaggi in tempo reale',
|
||||
'journey.new': 'Nuovo diario',
|
||||
@@ -1895,6 +1901,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.status.active': 'Attivo',
|
||||
'journey.status.completed': 'Completato',
|
||||
'journey.status.upcoming': 'In arrivo',
|
||||
'journey.status.archived': 'Archiviato',
|
||||
'journey.checkin.add': 'Check-in',
|
||||
'journey.checkin.namePlaceholder': 'Nome del luogo',
|
||||
'journey.checkin.notesPlaceholder': 'Note (facoltativo)',
|
||||
@@ -2048,6 +2055,11 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.name': 'Nome',
|
||||
'journey.settings.subtitle': 'Sottotitolo',
|
||||
'journey.settings.subtitlePlaceholder': 'es. Thailandia, Vietnam e Cambogia',
|
||||
'journey.settings.endJourney': 'Archivia il viaggio',
|
||||
'journey.settings.reopenJourney': 'Ripristina il viaggio',
|
||||
'journey.settings.archived': 'Viaggio archiviato',
|
||||
'journey.settings.reopened': 'Viaggio riaperto',
|
||||
'journey.settings.endDescription': 'Nasconde il badge In diretta. Puoi riaprire in qualsiasi momento.',
|
||||
'journey.settings.delete': 'Elimina',
|
||||
'journey.settings.deleteJourney': 'Elimina diario',
|
||||
'journey.settings.deleteMessage': 'Eliminare "{title}"? Tutte le voci e le foto andranno perse.',
|
||||
|
||||
@@ -1829,6 +1829,10 @@ const nl: Record<string, string> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Test-Ntfy mislukt',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin-Ntfy verstuurt altijd wanneer een onderwerp is geconfigureerd',
|
||||
'admin.notifications.adminNotificationsHint': 'Stel in via welke kanalen admin-meldingen worden bezorgd (bijv. versie-updates). De webhook verstuurt automatisch als er een admin-webhook-URL is ingesteld.',
|
||||
'admin.notifications.tripReminders.title': 'Reisherinneringen',
|
||||
'admin.notifications.tripReminders.hint': 'Stuurt een herinneringsmelding voor de start van een reis (vereist ingestelde herinneringsdagen bij de reis).',
|
||||
'admin.notifications.tripReminders.enabled': 'Reisherinneringen ingeschakeld',
|
||||
'admin.notifications.tripReminders.disabled': 'Reisherinneringen uitgeschakeld',
|
||||
'admin.tabs.notifications': 'Meldingen',
|
||||
'notifications.versionAvailable.title': 'Update beschikbaar',
|
||||
'notifications.versionAvailable.text': 'TREK {version} is nu beschikbaar.',
|
||||
@@ -1873,6 +1877,8 @@ const nl: Record<string, string> = {
|
||||
'common.justNow': 'zojuist',
|
||||
'common.hoursAgo': '{count}u geleden',
|
||||
'common.daysAgo': '{count}d geleden',
|
||||
'journey.search.placeholder': 'Reizen zoeken…',
|
||||
'journey.search.noResults': 'Geen reizen komen overeen met "{query}"',
|
||||
'journey.title': 'Reisverslag',
|
||||
'journey.subtitle': 'Leg je reizen vast terwijl je onderweg bent',
|
||||
'journey.new': 'Nieuw reisverslag',
|
||||
@@ -1894,6 +1900,7 @@ const nl: Record<string, string> = {
|
||||
'journey.status.active': 'Actief',
|
||||
'journey.status.completed': 'Voltooid',
|
||||
'journey.status.upcoming': 'Gepland',
|
||||
'journey.status.archived': 'Gearchiveerd',
|
||||
'journey.checkin.add': 'Inchecken',
|
||||
'journey.checkin.namePlaceholder': 'Locatienaam',
|
||||
'journey.checkin.notesPlaceholder': 'Notities (optioneel)',
|
||||
@@ -2047,6 +2054,11 @@ const nl: Record<string, string> = {
|
||||
'journey.settings.name': 'Naam',
|
||||
'journey.settings.subtitle': 'Ondertitel',
|
||||
'journey.settings.subtitlePlaceholder': 'bijv. Thailand, Vietnam & Cambodja',
|
||||
'journey.settings.endJourney': 'Reis archiveren',
|
||||
'journey.settings.reopenJourney': 'Reis herstellen',
|
||||
'journey.settings.archived': 'Reis gearchiveerd',
|
||||
'journey.settings.reopened': 'Reis heropend',
|
||||
'journey.settings.endDescription': 'Verbergt het Live-badge. Je kunt het altijd heropenen.',
|
||||
'journey.settings.delete': 'Verwijderen',
|
||||
'journey.settings.deleteJourney': 'Reisverslag verwijderen',
|
||||
'journey.settings.deleteMessage': '"{title}" verwijderen? Alle vermeldingen en foto\'s gaan verloren.',
|
||||
|
||||
@@ -1633,6 +1633,10 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Wysyłanie testowego Ntfy nie powiodło się',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy zawsze wysyła po skonfigurowaniu tematu',
|
||||
'admin.notifications.adminNotificationsHint': 'Skonfiguruj, które kanały dostarczają powiadomienia admina (np. alerty o wersjach). Webhook wysyła automatycznie, gdy ustawiony jest URL webhooka admina.',
|
||||
'admin.notifications.tripReminders.title': 'Przypomnienia o podróżach',
|
||||
'admin.notifications.tripReminders.hint': 'Wysyła powiadomienie z przypomnieniem przed rozpoczęciem podróży (wymaga ustawienia dni przypomnienia dla podróży).',
|
||||
'admin.notifications.tripReminders.enabled': 'Przypomnienia o podróżach włączone',
|
||||
'admin.notifications.tripReminders.disabled': 'Przypomnienia o podróżach wyłączone',
|
||||
'admin.webhook.hint': 'Pozwól użytkownikom konfigurować własne adresy URL webhooka dla powiadomień (Discord, Slack itp.).',
|
||||
'settings.notificationsDisabled': 'Powiadomienia nie są skonfigurowane.',
|
||||
'settings.notificationPreferences.noChannels': 'Brak skonfigurowanych kanałów powiadomień. Poproś administratora o skonfigurowanie powiadomień e-mail lub webhook.',
|
||||
@@ -1866,6 +1870,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.saveRouteNotConfigured': 'Trasa zapisu nie jest skonfigurowana dla tego dostawcy',
|
||||
'memories.testRouteNotConfigured': 'Trasa testowa nie jest skonfigurowana dla tego dostawcy',
|
||||
'memories.fillRequiredFields': 'Proszę wypełnić wszystkie wymagane pola',
|
||||
'journey.search.placeholder': 'Szukaj podróży…',
|
||||
'journey.search.noResults': 'Brak podróży pasujących do „{query}"',
|
||||
'journey.title': 'Dziennik podróży',
|
||||
'journey.subtitle': 'Dokumentuj swoje podróże na bieżąco',
|
||||
'journey.new': 'Nowy dziennik podróży',
|
||||
@@ -1887,6 +1893,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.status.active': 'Aktywny',
|
||||
'journey.status.completed': 'Zakończony',
|
||||
'journey.status.upcoming': 'Nadchodzący',
|
||||
'journey.status.archived': 'Zarchiwizowano',
|
||||
'journey.checkin.add': 'Zamelduj się',
|
||||
'journey.checkin.namePlaceholder': 'Nazwa miejsca',
|
||||
'journey.checkin.notesPlaceholder': 'Notatki (opcjonalnie)',
|
||||
@@ -2040,6 +2047,11 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.name': 'Nazwa',
|
||||
'journey.settings.subtitle': 'Podtytuł',
|
||||
'journey.settings.subtitlePlaceholder': 'np. Tajlandia, Wietnam i Kambodża',
|
||||
'journey.settings.endJourney': 'Archiwizuj podróż',
|
||||
'journey.settings.reopenJourney': 'Przywróć podróż',
|
||||
'journey.settings.archived': 'Podróż zarchiwizowana',
|
||||
'journey.settings.reopened': 'Podróż wznowiona',
|
||||
'journey.settings.endDescription': 'Ukrywa odznakę Na żywo. Możesz wznowić w dowolnym momencie.',
|
||||
'journey.settings.delete': 'Usuń',
|
||||
'journey.settings.deleteJourney': 'Usuń dziennik podróży',
|
||||
'journey.settings.deleteMessage': 'Usunąć „{title}"? Wszystkie wpisy i zdjęcia zostaną utracone.',
|
||||
|
||||
@@ -1826,6 +1826,10 @@ const ru: Record<string, string> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Ошибка отправки тестового Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Ntfy администратора всегда отправляется при наличии настроенной темы',
|
||||
'admin.notifications.adminNotificationsHint': 'Настройте, какие каналы доставляют уведомления администратора (например, оповещения о версиях). Вебхук отправляется автоматически, если задан URL вебхука администратора.',
|
||||
'admin.notifications.tripReminders.title': 'Напоминания о поездках',
|
||||
'admin.notifications.tripReminders.hint': 'Отправляет напоминание перед началом поездки (необходимо указать дни напоминания в параметрах поездки).',
|
||||
'admin.notifications.tripReminders.enabled': 'Напоминания о поездках включены',
|
||||
'admin.notifications.tripReminders.disabled': 'Напоминания о поездках отключены',
|
||||
'admin.tabs.notifications': 'Уведомления',
|
||||
'notifications.versionAvailable.title': 'Доступно обновление',
|
||||
'notifications.versionAvailable.text': 'TREK {version} теперь доступен.',
|
||||
@@ -1873,6 +1877,8 @@ const ru: Record<string, string> = {
|
||||
'memories.saveRouteNotConfigured': 'Маршрут сохранения не настроен для этого провайдера',
|
||||
'memories.testRouteNotConfigured': 'Маршрут тестирования не настроен для этого провайдера',
|
||||
'memories.fillRequiredFields': 'Пожалуйста, заполните все обязательные поля',
|
||||
'journey.search.placeholder': 'Поиск путешествий…',
|
||||
'journey.search.noResults': 'Путешествий по запросу «{query}» не найдено',
|
||||
'journey.title': 'Путешествие',
|
||||
'journey.subtitle': 'Отслеживайте свои путешествия в реальном времени',
|
||||
'journey.new': 'Новое путешествие',
|
||||
@@ -1894,6 +1900,7 @@ const ru: Record<string, string> = {
|
||||
'journey.status.active': 'Активно',
|
||||
'journey.status.completed': 'Завершено',
|
||||
'journey.status.upcoming': 'Предстоящее',
|
||||
'journey.status.archived': 'В архиве',
|
||||
'journey.checkin.add': 'Отметиться',
|
||||
'journey.checkin.namePlaceholder': 'Название места',
|
||||
'journey.checkin.notesPlaceholder': 'Заметки (необязательно)',
|
||||
@@ -2047,6 +2054,11 @@ const ru: Record<string, string> = {
|
||||
'journey.settings.name': 'Название',
|
||||
'journey.settings.subtitle': 'Подзаголовок',
|
||||
'journey.settings.subtitlePlaceholder': 'напр. Таиланд, Вьетнам и Камбоджа',
|
||||
'journey.settings.endJourney': 'Архивировать путешествие',
|
||||
'journey.settings.reopenJourney': 'Восстановить путешествие',
|
||||
'journey.settings.archived': 'Путешествие архивировано',
|
||||
'journey.settings.reopened': 'Путешествие возобновлено',
|
||||
'journey.settings.endDescription': 'Скрывает значок «В эфире». Вы можете возобновить в любое время.',
|
||||
'journey.settings.delete': 'Удалить',
|
||||
'journey.settings.deleteJourney': 'Удалить путешествие',
|
||||
'journey.settings.deleteMessage': 'Удалить «{title}»? Все записи и фото будут потеряны.',
|
||||
|
||||
@@ -1826,6 +1826,10 @@ const zh: Record<string, string> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': '测试 Ntfy 失败',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': '配置主题后管理员 Ntfy 始终触发',
|
||||
'admin.notifications.adminNotificationsHint': '配置哪些渠道发送管理员通知(如版本更新提醒)。设置管理员 Webhook URL 后,Webhook 将自动触发。',
|
||||
'admin.notifications.tripReminders.title': '行程提醒',
|
||||
'admin.notifications.tripReminders.hint': '在行程开始前发送提醒通知(需要在行程中设置提醒天数)。',
|
||||
'admin.notifications.tripReminders.enabled': '行程提醒已启用',
|
||||
'admin.notifications.tripReminders.disabled': '行程提醒已禁用',
|
||||
'admin.tabs.notifications': '通知',
|
||||
'notifications.versionAvailable.title': '有可用更新',
|
||||
'notifications.versionAvailable.text': 'TREK {version} 现已可用。',
|
||||
@@ -1873,6 +1877,8 @@ const zh: Record<string, string> = {
|
||||
'memories.saveRouteNotConfigured': '此提供商未配置保存路由',
|
||||
'memories.testRouteNotConfigured': '此提供商未配置测试路由',
|
||||
'memories.fillRequiredFields': '请填写所有必填字段',
|
||||
'journey.search.placeholder': '搜索旅程…',
|
||||
'journey.search.noResults': '没有与"{query}"匹配的旅程',
|
||||
'journey.title': '旅程',
|
||||
'journey.subtitle': '实时记录你的旅行',
|
||||
'journey.new': '新建旅程',
|
||||
@@ -1894,6 +1900,7 @@ const zh: Record<string, string> = {
|
||||
'journey.status.active': '进行中',
|
||||
'journey.status.completed': '已完成',
|
||||
'journey.status.upcoming': '即将开始',
|
||||
'journey.status.archived': '已归档',
|
||||
'journey.checkin.add': '签到',
|
||||
'journey.checkin.namePlaceholder': '地点名称',
|
||||
'journey.checkin.notesPlaceholder': '备注(可选)',
|
||||
@@ -2047,6 +2054,11 @@ const zh: Record<string, string> = {
|
||||
'journey.settings.name': '名称',
|
||||
'journey.settings.subtitle': '副标题',
|
||||
'journey.settings.subtitlePlaceholder': '例如 泰国、越南和柬埔寨',
|
||||
'journey.settings.endJourney': '归档旅程',
|
||||
'journey.settings.reopenJourney': '恢复旅程',
|
||||
'journey.settings.archived': '旅程已归档',
|
||||
'journey.settings.reopened': '旅程已重新开启',
|
||||
'journey.settings.endDescription': '隐藏直播标记。您可以随时重新开启。',
|
||||
'journey.settings.delete': '删除',
|
||||
'journey.settings.deleteJourney': '删除旅程',
|
||||
'journey.settings.deleteMessage': '删除"{title}"?所有条目和照片将丢失。',
|
||||
|
||||
@@ -251,6 +251,10 @@ const zhTw: Record<string, string> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': '測試 Ntfy 失敗',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': '設定主題後管理員 Ntfy 始終觸發',
|
||||
'admin.notifications.adminNotificationsHint': '配置哪些渠道傳遞僅管理員通知(例如版本提醒)。',
|
||||
'admin.notifications.tripReminders.title': '行程提醒',
|
||||
'admin.notifications.tripReminders.hint': '在行程開始前發送提醒通知(需要在行程中設定提醒天數)。',
|
||||
'admin.notifications.tripReminders.enabled': '行程提醒已啟用',
|
||||
'admin.notifications.tripReminders.disabled': '行程提醒已停用',
|
||||
'admin.smtp.title': '郵件與通知',
|
||||
'admin.smtp.hint': '用於傳送電子郵件通知的 SMTP 配置。',
|
||||
'admin.smtp.testButton': '傳送測試郵件',
|
||||
@@ -1833,6 +1837,8 @@ const zhTw: Record<string, string> = {
|
||||
'memories.saveRouteNotConfigured': '此提供商未設定儲存路由',
|
||||
'memories.testRouteNotConfigured': '此提供商未設定測試路由',
|
||||
'memories.fillRequiredFields': '請填寫所有必填欄位',
|
||||
'journey.search.placeholder': '搜尋旅程…',
|
||||
'journey.search.noResults': '沒有符合「{query}」的旅程',
|
||||
'journey.title': '旅程',
|
||||
'journey.subtitle': '即時記錄你的旅行',
|
||||
'journey.new': '新建旅程',
|
||||
@@ -1854,6 +1860,7 @@ const zhTw: Record<string, string> = {
|
||||
'journey.status.active': '進行中',
|
||||
'journey.status.completed': '已完成',
|
||||
'journey.status.upcoming': '即將開始',
|
||||
'journey.status.archived': '已封存',
|
||||
'journey.checkin.add': '打卡',
|
||||
'journey.checkin.namePlaceholder': '地點名稱',
|
||||
'journey.checkin.notesPlaceholder': '備註(可選)',
|
||||
@@ -2007,6 +2014,11 @@ const zhTw: Record<string, string> = {
|
||||
'journey.settings.name': '名稱',
|
||||
'journey.settings.subtitle': '副標題',
|
||||
'journey.settings.subtitlePlaceholder': '例如 泰國、越南和柬埔寨',
|
||||
'journey.settings.endJourney': '封存旅程',
|
||||
'journey.settings.reopenJourney': '還原旅程',
|
||||
'journey.settings.archived': '旅程已封存',
|
||||
'journey.settings.reopened': '旅程已重新開啟',
|
||||
'journey.settings.endDescription': '隱藏直播標記。您可以隨時重新開啟。',
|
||||
'journey.settings.delete': '刪除',
|
||||
'journey.settings.deleteJourney': '刪除旅程',
|
||||
'journey.settings.deleteMessage': '刪除「{title}」?所有條目和照片將遺失。',
|
||||
|
||||
@@ -1180,6 +1180,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
const emailActive = activeChans.includes('email')
|
||||
const webhookActive = activeChans.includes('webhook')
|
||||
const ntfyActive = activeChans.includes('ntfy')
|
||||
const tripRemindersActive = smtpValues.notify_trip_reminder !== 'false'
|
||||
|
||||
const setChannels = async (email: boolean, webhook: boolean, ntfy: boolean) => {
|
||||
const chans = [email && 'email', webhook && 'webhook', ntfy && 'ntfy'].filter(Boolean).join(',') || 'none'
|
||||
@@ -1338,6 +1339,37 @@ export default function AdminPage(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trip Reminders Toggle */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.notifications.tripReminders.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.tripReminders.hint')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const next = !tripRemindersActive
|
||||
setSmtpValues(prev => ({ ...prev, notify_trip_reminder: next ? 'true' : 'false' }))
|
||||
try {
|
||||
await authApi.updateAppSettings({ notify_trip_reminder: next ? 'true' : 'false' })
|
||||
toast.success(next ? t('admin.notifications.tripReminders.enabled') : t('admin.notifications.tripReminders.disabled'))
|
||||
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
|
||||
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
|
||||
}).catch(() => {})
|
||||
} catch {
|
||||
setSmtpValues(prev => ({ ...prev, notify_trip_reminder: tripRemindersActive ? 'true' : 'false' }))
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0"
|
||||
style={{ background: tripRemindersActive ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: tripRemindersActive ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Webhook Panel */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
|
||||
@@ -176,7 +176,7 @@ const mockJourneyDetail = {
|
||||
avatar: null,
|
||||
},
|
||||
],
|
||||
stats: { entries: 2, photos: 1, cities: 2 },
|
||||
stats: { entries: 2, photos: 1, places: 2 },
|
||||
};
|
||||
|
||||
// ── MSW Handlers ─────────────────────────────────────────────────────────────
|
||||
@@ -362,12 +362,12 @@ describe('JourneyDetailPage', () => {
|
||||
expect(screen.getAllByText('Days').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Entries').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Photos').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Cities').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Places').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('renders stat values', async () => {
|
||||
await renderAndWait();
|
||||
// stats.entries = 2, stats.photos = 1, stats.cities = 2
|
||||
// stats.entries = 2, stats.photos = 1, stats.places = 2
|
||||
// Entries count appears in hero and sidebar
|
||||
const twos = screen.getAllByText('2');
|
||||
expect(twos.length).toBeGreaterThanOrEqual(1);
|
||||
@@ -474,7 +474,7 @@ describe('JourneyDetailPage', () => {
|
||||
// ── FE-PAGE-JOURNEYDETAIL-018 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-018: Empty state when no entries', () => {
|
||||
it('shows "No entries yet" when journey has no entries', async () => {
|
||||
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, cities: 0 } });
|
||||
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, places: 0 } });
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
|
||||
@@ -484,7 +484,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
it('shows hint text to add a trip', async () => {
|
||||
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, cities: 0 } });
|
||||
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, places: 0 } });
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
|
||||
@@ -567,7 +567,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [multiPhotoEntry, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 3, cities: 2 },
|
||||
stats: { entries: 2, photos: 3, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -610,7 +610,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [...mockJourneyDetail.entries, skeletonEntry],
|
||||
stats: { entries: 3, photos: 1, cities: 3 },
|
||||
stats: { entries: 3, photos: 1, places: 3 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -650,7 +650,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [...mockJourneyDetail.entries, checkinEntry],
|
||||
stats: { entries: 3, photos: 1, cities: 2 },
|
||||
stats: { entries: 3, photos: 1, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -707,15 +707,26 @@ describe('JourneyDetailPage', () => {
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-030 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-030: Active status badge shows Live indicator', () => {
|
||||
it('renders a "Live" badge for active journeys', async () => {
|
||||
it('renders a "Live" badge when linked trip spans today', async () => {
|
||||
setupDefaultHandlers({
|
||||
trips: [{ trip_id: 5, added_at: now, title: 'Current Trip', start_date: '2020-01-01', end_date: '2099-12-31', cover_image: null, currency: 'EUR', place_count: 8 }],
|
||||
});
|
||||
await renderAndWait();
|
||||
expect(screen.getByText('Live')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render "Live" badge when linked trip is in the past', async () => {
|
||||
await renderAndWait();
|
||||
expect(screen.queryByText('Live')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-031 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-031: Synced with Trips badge renders', () => {
|
||||
it('renders the "Synced with Trips" text in the hero', async () => {
|
||||
it('renders the "Synced with Trips" text in the hero for live journeys', async () => {
|
||||
setupDefaultHandlers({
|
||||
trips: [{ trip_id: 5, added_at: now, title: 'Current Trip', start_date: '2020-01-01', end_date: '2099-12-31', cover_image: null, currency: 'EUR', place_count: 8 }],
|
||||
});
|
||||
await renderAndWait();
|
||||
expect(screen.getByText('Synced with Trips')).toBeInTheDocument();
|
||||
});
|
||||
@@ -741,7 +752,7 @@ describe('JourneyDetailPage', () => {
|
||||
it('shows the place count in the sidebar map', async () => {
|
||||
await renderAndWait();
|
||||
// The sidebar map shows "N Places" text
|
||||
expect(screen.getByText(/Places/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Places/).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1717,7 +1728,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [emptyEntry],
|
||||
stats: { entries: 1, photos: 0, cities: 1 },
|
||||
stats: { entries: 1, photos: 0, places: 1 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -1930,7 +1941,7 @@ describe('JourneyDetailPage', () => {
|
||||
{ ...mockJourneyDetail.entries[0], id: 10, entry_date: '2026-03-15' },
|
||||
{ ...mockJourneyDetail.entries[1], id: 11, entry_date: '2026-03-15', location_lat: 41.95, location_lng: 12.55 },
|
||||
];
|
||||
setupDefaultHandlers({ entries: twoOnSameDay, stats: { entries: 2, photos: 1, cities: 2 } });
|
||||
setupDefaultHandlers({ entries: twoOnSameDay, stats: { entries: 2, photos: 1, places: 2 } });
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
await waitFor(() => {
|
||||
@@ -2005,7 +2016,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [immichEntry, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 1, cities: 2 },
|
||||
stats: { entries: 2, photos: 1, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -2039,7 +2050,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [synologyEntry, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 1, cities: 2 },
|
||||
stats: { entries: 2, photos: 1, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -2636,7 +2647,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [multiPhotoEntry, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 5, cities: 2 },
|
||||
stats: { entries: 2, photos: 5, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -2661,7 +2672,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [twoPhotoEntry, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 2, cities: 2 },
|
||||
stats: { entries: 2, photos: 2, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -3045,7 +3056,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [mockJourneyDetail.entries[0], noLocEntry],
|
||||
stats: { entries: 2, photos: 1, cities: 1 },
|
||||
stats: { entries: 2, photos: 1, places: 1 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -3528,7 +3539,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [entryWithMultiPhotos, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 2, cities: 2 },
|
||||
stats: { entries: 2, photos: 2, places: 2 },
|
||||
});
|
||||
|
||||
server.use(
|
||||
@@ -3620,7 +3631,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [mockJourneyDetail.entries[0], noTitleEntry],
|
||||
stats: { entries: 2, photos: 1, cities: 2 },
|
||||
stats: { entries: 2, photos: 1, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
|
||||
@@ -24,6 +24,7 @@ import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
||||
import MobileEntryView from '../components/Journey/MobileEntryView'
|
||||
import { useIsMobile } from '../hooks/useIsMobile'
|
||||
import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore'
|
||||
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||
|
||||
const GRADIENTS = [
|
||||
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
|
||||
@@ -207,6 +208,14 @@ export default function JourneyDetailPage() {
|
||||
const dayGroups = groupByDate(timelineEntries)
|
||||
const sortedDates = [...dayGroups.keys()].sort()
|
||||
|
||||
const tripDateMin = current.trips.length
|
||||
? current.trips.reduce((min: string, t: any) => t.start_date && (!min || t.start_date < min) ? t.start_date : min, '')
|
||||
: null
|
||||
const tripDateMax = current.trips.length
|
||||
? current.trips.reduce((max: string, t: any) => t.end_date && (!max || t.end_date > max) ? t.end_date : max, '')
|
||||
: null
|
||||
const lifecycle = computeJourneyLifecycle(current.status, tripDateMin || null, tripDateMax || null)
|
||||
|
||||
const showMobileCombined = isMobile && view === 'timeline'
|
||||
|
||||
return (
|
||||
@@ -283,16 +292,28 @@ export default function JourneyDetailPage() {
|
||||
<div className="relative z-[3] flex items-center justify-between mb-5">
|
||||
{/* Desktop: badges */}
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
{current.status === 'active' && (
|
||||
{lifecycle === 'live' && (
|
||||
<div className="inline-flex items-center gap-2 px-2.5 py-1 bg-white/15 backdrop-blur rounded-full text-[10px] font-semibold uppercase">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
Live
|
||||
{t('journey.frontpage.live')}
|
||||
</div>
|
||||
)}
|
||||
{lifecycle !== 'archived' && current.trips.length > 0 && (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
<RefreshCw size={11} />
|
||||
{t('journey.detail.syncedWithTrips')}
|
||||
</div>
|
||||
)}
|
||||
{lifecycle !== 'live' && lifecycle !== 'archived' && (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
{t(`journey.status.${lifecycle === 'upcoming' ? 'upcoming' : lifecycle === 'draft' ? 'draft' : 'completed'}`)}
|
||||
</div>
|
||||
)}
|
||||
{lifecycle === 'archived' && (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
{t('journey.status.archived')}
|
||||
</div>
|
||||
)}
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
<RefreshCw size={11} />
|
||||
{t('journey.detail.syncedWithTrips')}
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile: back button on the left */}
|
||||
<button
|
||||
@@ -331,7 +352,7 @@ export default function JourneyDetailPage() {
|
||||
<div className="flex gap-8">
|
||||
{[
|
||||
{ value: sortedDates.length, label: t('journey.stats.days') },
|
||||
{ value: current.stats.cities, label: t('journey.stats.cities') },
|
||||
{ value: current.stats.places, label: t('journey.stats.places') },
|
||||
{ value: current.stats.entries, label: t('journey.stats.entries') },
|
||||
{ value: current.stats.photos, label: t('journey.stats.photos') },
|
||||
].map(s => (
|
||||
@@ -494,7 +515,7 @@ export default function JourneyDetailPage() {
|
||||
{ value: sortedDates.length, label: t('journey.stats.days') },
|
||||
{ value: current.stats.entries, label: t('journey.stats.entries') },
|
||||
{ value: current.stats.photos, label: t('journey.stats.photos') },
|
||||
{ value: current.stats.cities, label: t('journey.stats.cities') },
|
||||
{ value: current.stats.places, label: t('journey.stats.places') },
|
||||
].map(s => (
|
||||
<div key={s.label} className="rounded-lg bg-zinc-50 dark:bg-zinc-800/60 border border-zinc-100 dark:border-zinc-700/50 px-3 py-2.5">
|
||||
<div className="text-[18px] font-bold tracking-[-0.02em] text-zinc-900 dark:text-white leading-none mb-0.5">{s.value}</div>
|
||||
@@ -2820,6 +2841,21 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
}
|
||||
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [archiving, setArchiving] = useState(false)
|
||||
|
||||
const handleArchiveToggle = async () => {
|
||||
setArchiving(true)
|
||||
try {
|
||||
const newStatus = journey.status === 'archived' ? 'active' : 'archived'
|
||||
await updateJourney(journey.id, { status: newStatus })
|
||||
toast.success(newStatus === 'archived' ? t('journey.settings.archived') : t('journey.settings.reopened'))
|
||||
onSaved()
|
||||
} catch {
|
||||
toast.error(t('journey.settings.saveFailed'))
|
||||
} finally {
|
||||
setArchiving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
@@ -2947,11 +2983,19 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
<div className="flex flex-wrap items-center gap-2 px-6 py-4 pb-6 md:pb-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="flex items-center gap-1.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg px-2.5 py-2 mr-auto"
|
||||
className="flex items-center gap-1.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg px-2.5 py-2"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
{t('journey.settings.delete')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleArchiveToggle}
|
||||
disabled={archiving}
|
||||
className="flex items-center gap-1.5 text-[12px] font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700 rounded-lg px-2.5 py-2 mr-auto disabled:opacity-40"
|
||||
title={t('journey.settings.endDescription')}
|
||||
>
|
||||
{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}
|
||||
</button>
|
||||
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
||||
<button onClick={handleSave} disabled={saving || !title.trim()} className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40">
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
|
||||
@@ -43,7 +43,9 @@ function buildJourneyListItem(overrides: Record<string, unknown> = {}) {
|
||||
status: 'draft' as const,
|
||||
entry_count: 0,
|
||||
photo_count: 0,
|
||||
city_count: 0,
|
||||
place_count: 0,
|
||||
trip_date_min: null as string | null,
|
||||
trip_date_max: null as string | null,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
...overrides,
|
||||
@@ -194,7 +196,7 @@ describe('JourneyPage', () => {
|
||||
|
||||
// FE-PAGE-JOURNEY-008
|
||||
it('FE-PAGE-JOURNEY-008: shows active journey hero when active journey exists', async () => {
|
||||
const active = buildJourneyListItem({ id: 10, title: 'Active Trip', status: 'active' });
|
||||
const active = buildJourneyListItem({ id: 10, title: 'Active Trip', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
|
||||
const other = buildJourneyListItem({ id: 11, title: 'Completed Trip', status: 'completed' });
|
||||
setupDefaultHandlers([active, other]);
|
||||
|
||||
@@ -320,13 +322,13 @@ describe('JourneyPage', () => {
|
||||
});
|
||||
|
||||
// FE-PAGE-JOURNEY-013
|
||||
it('FE-PAGE-JOURNEY-013: journey card shows entry/photo/city counts', async () => {
|
||||
it('FE-PAGE-JOURNEY-013: journey card shows entry/photo/place counts', async () => {
|
||||
const j1 = buildJourneyListItem({
|
||||
id: 20,
|
||||
title: 'Stats Journey',
|
||||
entry_count: 12,
|
||||
photo_count: 47,
|
||||
city_count: 5,
|
||||
place_count: 5,
|
||||
});
|
||||
setupDefaultHandlers([j1]);
|
||||
|
||||
@@ -335,7 +337,7 @@ describe('JourneyPage', () => {
|
||||
expect(screen.getByText('Stats Journey')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The card renders entry_count, photo_count, city_count values
|
||||
// The card renders entry_count, photo_count, place_count values
|
||||
expect(screen.getByText('12')).toBeInTheDocument();
|
||||
expect(screen.getByText('47')).toBeInTheDocument();
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
@@ -361,6 +363,8 @@ describe('JourneyPage', () => {
|
||||
id: 40,
|
||||
title: 'Recent Active',
|
||||
status: 'active',
|
||||
trip_date_min: '2020-01-01',
|
||||
trip_date_max: '2099-12-31',
|
||||
updated_at: Date.now() - 60000, // 1 minute ago
|
||||
});
|
||||
setupDefaultHandlers([active]);
|
||||
@@ -380,6 +384,8 @@ describe('JourneyPage', () => {
|
||||
id: 41,
|
||||
title: 'Hours Active',
|
||||
status: 'active',
|
||||
trip_date_min: '2020-01-01',
|
||||
trip_date_max: '2099-12-31',
|
||||
updated_at: Date.now() - 3 * 3600000, // 3 hours ago
|
||||
});
|
||||
setupDefaultHandlers([active]);
|
||||
@@ -399,6 +405,8 @@ describe('JourneyPage', () => {
|
||||
id: 42,
|
||||
title: 'Days Active',
|
||||
status: 'active',
|
||||
trip_date_min: '2020-01-01',
|
||||
trip_date_max: '2099-12-31',
|
||||
updated_at: Date.now() - 5 * 24 * 3600000, // 5 days ago
|
||||
});
|
||||
setupDefaultHandlers([active]);
|
||||
@@ -414,7 +422,7 @@ describe('JourneyPage', () => {
|
||||
|
||||
// FE-PAGE-JOURNEY-018
|
||||
it('FE-PAGE-JOURNEY-018: active journey hero shows "Continue writing" button', async () => {
|
||||
const active = buildJourneyListItem({ id: 50, title: 'Writing Journey', status: 'active' });
|
||||
const active = buildJourneyListItem({ id: 50, title: 'Writing Journey', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
|
||||
setupDefaultHandlers([active]);
|
||||
|
||||
render(<JourneyPage />);
|
||||
@@ -427,7 +435,7 @@ describe('JourneyPage', () => {
|
||||
|
||||
// FE-PAGE-JOURNEY-019
|
||||
it('FE-PAGE-JOURNEY-019: active journey hero shows Live and Synced badges', async () => {
|
||||
const active = buildJourneyListItem({ id: 51, title: 'Live Journey', status: 'active' });
|
||||
const active = buildJourneyListItem({ id: 51, title: 'Live Journey', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
|
||||
setupDefaultHandlers([active]);
|
||||
|
||||
render(<JourneyPage />);
|
||||
@@ -442,7 +450,7 @@ describe('JourneyPage', () => {
|
||||
// FE-PAGE-JOURNEY-020
|
||||
it('FE-PAGE-JOURNEY-020: clicking active journey hero navigates to its detail page', async () => {
|
||||
const user = userEvent.setup();
|
||||
const active = buildJourneyListItem({ id: 60, title: 'Clickable Hero', status: 'active' });
|
||||
const active = buildJourneyListItem({ id: 60, title: 'Clickable Hero', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
|
||||
setupDefaultHandlers([active]);
|
||||
|
||||
render(<JourneyPage />);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useEffect, useState, useMemo, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useJourneyStore } from '../store/journeyStore'
|
||||
import { journeyApi } from '../api/client'
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Check, X, ChevronRight, RefreshCw, Users,
|
||||
} from 'lucide-react'
|
||||
import type { Journey } from '../store/journeyStore'
|
||||
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||
|
||||
const GRADIENTS = [
|
||||
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
|
||||
@@ -43,6 +44,9 @@ export default function JourneyPage() {
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [availableTrips, setAvailableTrips] = useState<any[]>([])
|
||||
const [selectedTripIds, setSelectedTripIds] = useState<Set<number>>(new Set())
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// suggestion
|
||||
const [suggestions, setSuggestions] = useState<any[]>([])
|
||||
@@ -56,12 +60,22 @@ export default function JourneyPage() {
|
||||
const activeSuggestion = suggestions.find(s => !dismissedSuggestions.has(s.id))
|
||||
|
||||
const activeJourney = useMemo(() => {
|
||||
return journeys.find(j => j.status === 'active') || null
|
||||
}, [journeys])
|
||||
if (searchQuery.trim()) return null
|
||||
return journeys.find(j => {
|
||||
const j2 = j as any
|
||||
return computeJourneyLifecycle(j.status, j2.trip_date_min, j2.trip_date_max) === 'live'
|
||||
}) || null
|
||||
}, [journeys, searchQuery])
|
||||
|
||||
const otherJourneys = useMemo(() => {
|
||||
return journeys.filter(j => j.id !== activeJourney?.id)
|
||||
}, [journeys, activeJourney])
|
||||
const filteredJourneys = useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase()
|
||||
if (!q) return journeys.filter(j => j.id !== activeJourney?.id)
|
||||
return journeys.filter(j => {
|
||||
const inTitle = j.title.toLowerCase().includes(q)
|
||||
const inSubtitle = j.subtitle?.toLowerCase().includes(q) ?? false
|
||||
return inTitle || inSubtitle
|
||||
})
|
||||
}, [journeys, activeJourney, searchQuery])
|
||||
|
||||
const openCreateModal = async (preSelectedTripId?: number) => {
|
||||
setShowCreate(true)
|
||||
@@ -99,15 +113,41 @@ export default function JourneyPage() {
|
||||
<div style={{ paddingTop: 'var(--nav-h, 56px)' }}>
|
||||
<div className="max-w-[1440px] mx-auto">
|
||||
|
||||
{/* Header — mobile: just a create button */}
|
||||
<div className="md:hidden px-5 pt-5 pb-4">
|
||||
<button
|
||||
onClick={() => openCreateModal()}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[14px] font-semibold active:scale-[0.98] transition-transform"
|
||||
>
|
||||
<Plus size={16} strokeWidth={2.5} />
|
||||
{t('journey.frontpage.createJourney')}
|
||||
</button>
|
||||
{/* Header — mobile */}
|
||||
<div className="md:hidden px-5 pt-5 pb-4 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (searchOpen) {
|
||||
setSearchOpen(false)
|
||||
setSearchQuery('')
|
||||
} else {
|
||||
setSearchOpen(true)
|
||||
setTimeout(() => searchInputRef.current?.focus(), 50)
|
||||
}
|
||||
}}
|
||||
className="w-10 h-10 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 flex items-center justify-center text-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex-shrink-0"
|
||||
>
|
||||
{searchOpen ? <X size={15} /> : <Search size={15} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openCreateModal()}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[14px] font-semibold active:scale-[0.98] transition-transform"
|
||||
>
|
||||
<Plus size={16} strokeWidth={2.5} />
|
||||
{t('journey.frontpage.createJourney')}
|
||||
</button>
|
||||
</div>
|
||||
{searchOpen && (
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Escape') { setSearchQuery(''); setSearchOpen(false) } }}
|
||||
placeholder={t('journey.search.placeholder')}
|
||||
className="w-full px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white focus:border-zinc-400 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header — desktop */}
|
||||
@@ -117,8 +157,24 @@ export default function JourneyPage() {
|
||||
<p className="text-[13px] text-zinc-500 mt-1.5">{t("journey.frontpage.subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="w-9 h-9 rounded-[10px] border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 flex items-center justify-center text-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-700">
|
||||
<Search size={15} />
|
||||
{searchOpen && (
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Escape') { setSearchQuery(''); setSearchOpen(false) } }}
|
||||
placeholder={t('journey.search.placeholder')}
|
||||
autoFocus
|
||||
className="w-52 px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-[10px] text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white focus:border-zinc-400 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchOpen(s => !s)
|
||||
if (searchOpen) setSearchQuery('')
|
||||
}}
|
||||
className={`w-9 h-9 rounded-[10px] border border-zinc-200 dark:border-zinc-700 flex items-center justify-center text-zinc-500 transition-colors ${searchOpen ? 'bg-zinc-100 dark:bg-zinc-700' : 'bg-white dark:bg-zinc-800 hover:bg-zinc-50 dark:hover:bg-zinc-700'}`}
|
||||
>
|
||||
{searchOpen ? <X size={15} /> : <Search size={15} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openCreateModal()}
|
||||
@@ -226,7 +282,7 @@ export default function JourneyPage() {
|
||||
{[
|
||||
{ val: (activeJourney as any).entry_count ?? '--', label: t("journey.stats.entries") },
|
||||
{ val: (activeJourney as any).photo_count ?? '--', label: t("journey.stats.photos") },
|
||||
{ val: (activeJourney as any).city_count ?? '--', label: t("journey.stats.cities") },
|
||||
{ val: (activeJourney as any).place_count ?? '--', label: t("journey.stats.places") },
|
||||
].map(s => (
|
||||
<div key={s.label} className="flex flex-col gap-1">
|
||||
<span className="text-[28px] font-extrabold tracking-[-0.02em] leading-none">{s.val}</span>
|
||||
@@ -243,11 +299,24 @@ export default function JourneyPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search results info */}
|
||||
{searchQuery.trim() && (
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<span className="text-[13px] text-zinc-500">
|
||||
{filteredJourneys.length === 0
|
||||
? t('journey.search.noResults', { query: searchQuery.trim() })
|
||||
: `${filteredJourneys.length} ${t('journey.frontpage.journeys')}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Journeys */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-[11px] font-bold tracking-[0.14em] uppercase text-zinc-500">{t("journey.frontpage.allJourneys")}</span>
|
||||
<span className="text-[11px] text-zinc-400">{journeys.length} {t('journey.frontpage.journeys')}</span>
|
||||
</div>
|
||||
{!searchQuery.trim() && (
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-[11px] font-bold tracking-[0.14em] uppercase text-zinc-500">{t("journey.frontpage.allJourneys")}</span>
|
||||
<span className="text-[11px] text-zinc-400">{journeys.length} {t('journey.frontpage.journeys')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && journeys.length === 0 ? (
|
||||
<div className="flex justify-center py-16">
|
||||
@@ -255,7 +324,7 @@ export default function JourneyPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-[18px]">
|
||||
{otherJourneys.map(j => (
|
||||
{filteredJourneys.map(j => (
|
||||
<JourneyCard key={j.id} journey={j} onClick={() => navigate(`/journey/${j.id}`)} />
|
||||
))}
|
||||
|
||||
@@ -386,12 +455,13 @@ export default function JourneyPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?: number; photo_count?: number; city_count?: number }; onClick: () => void }) {
|
||||
function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?: number; photo_count?: number; place_count?: number; trip_date_min?: string | null; trip_date_max?: string | null }; onClick: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const j = journey
|
||||
const entryCount = j.entry_count ?? 0
|
||||
const photoCount = j.photo_count ?? 0
|
||||
const cityCount = j.city_count ?? 0
|
||||
const placeCount = j.place_count ?? 0
|
||||
const lifecycle = computeJourneyLifecycle(j.status, j.trip_date_min, j.trip_date_max)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -424,15 +494,22 @@ function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?:
|
||||
{j.subtitle && (
|
||||
<p className="text-[12px] text-zinc-500 mt-1">{j.subtitle}</p>
|
||||
)}
|
||||
{j.status === 'draft' && (
|
||||
<span className="inline-flex self-start mt-1.5 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 uppercase tracking-wide">{t('journey.status.draft')}</span>
|
||||
{lifecycle !== 'live' && (
|
||||
<span className={`inline-flex self-start mt-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wide ${
|
||||
lifecycle === 'archived' ? 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500' :
|
||||
lifecycle === 'upcoming' ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' :
|
||||
lifecycle === 'completed' ? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400' :
|
||||
'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'
|
||||
}`}>
|
||||
{t(`journey.status.${lifecycle}`)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-2.5 mt-auto pt-3.5 border-t border-zinc-100 dark:border-zinc-800" style={{ marginTop: j.subtitle ? 14 : 'auto' }}>
|
||||
{[
|
||||
{ val: entryCount, label: t('journey.stats.entries') },
|
||||
{ val: photoCount, label: t('journey.stats.photos') },
|
||||
{ val: cityCount, label: t('journey.stats.cities') },
|
||||
{ val: placeCount, label: t('journey.stats.places') },
|
||||
].map(s => (
|
||||
<div key={s.label} className="flex flex-col gap-1">
|
||||
<span className={`text-[16px] font-bold leading-none tracking-[-0.01em] ${s.val > 0 ? 'text-zinc-900 dark:text-white' : 'text-zinc-300 dark:text-zinc-600'}`}>
|
||||
|
||||
@@ -109,7 +109,7 @@ const mockJourneyData = {
|
||||
stats: {
|
||||
entries: 2,
|
||||
photos: 1,
|
||||
cities: 2,
|
||||
places: 2,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -354,7 +354,7 @@ describe('JourneyPublicPage', () => {
|
||||
],
|
||||
},
|
||||
],
|
||||
stats: { entries: 1, photos: 3, cities: 0 },
|
||||
stats: { entries: 1, photos: 3, places: 0 },
|
||||
};
|
||||
|
||||
server.use(
|
||||
@@ -383,7 +383,7 @@ describe('JourneyPublicPage', () => {
|
||||
it('FE-PAGE-PUBLICJOURNEY-015: stats display shows entries, photos, and cities counts', async () => {
|
||||
const customData = {
|
||||
...mockJourneyData,
|
||||
stats: { entries: 14, photos: 83, cities: 7 },
|
||||
stats: { entries: 14, photos: 83, places: 7 },
|
||||
};
|
||||
server.use(
|
||||
http.get('/api/public/journey/test-share-token', () => HttpResponse.json(customData)),
|
||||
|
||||
@@ -176,7 +176,7 @@ export default function JourneyPublicPage() {
|
||||
<span style={{ fontSize: 11, opacity: 0.4 }}>·</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><Camera size={12} /> {stats.photos} {t('journey.stats.photos')}</span>
|
||||
<span style={{ fontSize: 11, opacity: 0.4 }}>·</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><MapPin size={12} /> {stats.cities} {t('journey.stats.places')}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><MapPin size={12} /> {stats.places} {t('journey.stats.places')}</span>
|
||||
</div>
|
||||
|
||||
<div className="relative" style={{ marginTop: 12, fontSize: 9, fontWeight: 500, letterSpacing: 1.5, textTransform: 'uppercase', opacity: 0.25 }}>{t('journey.public.readOnly')}</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface Journey {
|
||||
subtitle?: string | null
|
||||
cover_gradient?: string | null
|
||||
cover_image?: string | null
|
||||
status: 'draft' | 'active' | 'completed'
|
||||
status: 'draft' | 'active' | 'completed' | 'archived'
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
@@ -81,7 +81,7 @@ export interface JourneyDetail extends Journey {
|
||||
entries: JourneyEntry[]
|
||||
trips: JourneyTrip[]
|
||||
contributors: JourneyContributor[]
|
||||
stats: { entries: number; photos: number; cities: number }
|
||||
stats: { entries: number; photos: number; places: number }
|
||||
hide_skeletons?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
export type JourneyLifecycle = 'archived' | 'live' | 'upcoming' | 'completed' | 'draft'
|
||||
|
||||
export function computeJourneyLifecycle(
|
||||
status: string,
|
||||
tripDateMin: string | null | undefined,
|
||||
tripDateMax: string | null | undefined,
|
||||
): JourneyLifecycle {
|
||||
if (status === 'archived') return 'archived'
|
||||
|
||||
if (tripDateMin && tripDateMax) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
if (tripDateMin <= today && today <= tripDateMax) return 'live'
|
||||
if (tripDateMin > today) return 'upcoming'
|
||||
return 'completed'
|
||||
}
|
||||
|
||||
if (!tripDateMin && !tripDateMax) {
|
||||
return 'draft'
|
||||
}
|
||||
|
||||
// Single boundary: only start or only end
|
||||
if (tripDateMin && !tripDateMax) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
return tripDateMin > today ? 'upcoming' : 'live'
|
||||
}
|
||||
if (!tripDateMin && tripDateMax) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
return tripDateMax < today ? 'completed' : 'live'
|
||||
}
|
||||
|
||||
return 'completed'
|
||||
}
|
||||
@@ -166,14 +166,9 @@ function startTripReminders(): void {
|
||||
const reminderEnabled = getSetting('notify_trip_reminder') !== 'false';
|
||||
const channelsRaw = getSetting('notification_channels') || getSetting('notification_channel') || 'none';
|
||||
const activeChannels = channelsRaw === 'none' ? [] : channelsRaw.split(',').map((c: string) => c.trim());
|
||||
const hasEmail = activeChannels.includes('email') && !!(getSetting('smtp_host') || '').trim();
|
||||
const hasWebhook = activeChannels.includes('webhook');
|
||||
const channelReady = hasEmail || hasWebhook;
|
||||
|
||||
if (!channelReady || !reminderEnabled) {
|
||||
if (!reminderEnabled) {
|
||||
const { logInfo: li } = require('./services/auditLog');
|
||||
const reason = !channelReady ? 'no notification channels configured' : 'trip reminders disabled in settings';
|
||||
li(`Trip reminders: disabled (${reason})`);
|
||||
li('Trip reminders: disabled in settings');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ const ADMIN_SETTINGS_KEYS = [
|
||||
'allow_registration', 'allowed_file_types', 'require_mfa',
|
||||
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify',
|
||||
'notification_channels', 'admin_webhook_url', 'admin_ntfy_server', 'admin_ntfy_topic', 'admin_ntfy_token',
|
||||
'notify_trip_reminder',
|
||||
'password_login', 'password_registration', 'oidc_login', 'oidc_registration',
|
||||
];
|
||||
|
||||
@@ -227,8 +228,7 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
||||
const notifChannelsRaw = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channels'").get() as { value: string } | undefined)?.value || notifChannel;
|
||||
const activeChannels = notifChannelsRaw === 'none' ? [] : notifChannelsRaw.split(',').map((c: string) => c.trim()).filter(Boolean);
|
||||
const hasWebhookEnabled = activeChannels.includes('webhook');
|
||||
const channelConfigured = (activeChannels.includes('email') && hasSmtpHost) || hasWebhookEnabled;
|
||||
const tripRemindersEnabled = channelConfigured && tripReminderSetting !== 'false';
|
||||
const tripRemindersEnabled = tripReminderSetting !== 'false';
|
||||
const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get());
|
||||
|
||||
return {
|
||||
|
||||
@@ -59,12 +59,14 @@ export function listJourneys(userId: number) {
|
||||
SELECT DISTINCT j.*,
|
||||
(SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count,
|
||||
(SELECT COUNT(*) FROM journey_photos jp JOIN journey_entries je2 ON jp.entry_id = je2.id WHERE je2.journey_id = j.id) as photo_count,
|
||||
(SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as city_count
|
||||
(SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as place_count,
|
||||
(SELECT MIN(t.start_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_min,
|
||||
(SELECT MAX(t.end_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_max
|
||||
FROM journeys j
|
||||
LEFT JOIN journey_contributors jc ON j.id = jc.journey_id AND jc.user_id = ?
|
||||
WHERE j.user_id = ? OR jc.user_id = ?
|
||||
ORDER BY j.updated_at DESC
|
||||
`).all(userId, userId, userId) as (Journey & { entry_count: number; photo_count: number; city_count: number })[];
|
||||
`).all(userId, userId, userId) as (Journey & { entry_count: number; photo_count: number; place_count: number; trip_date_min: string | null; trip_date_max: string | null })[];
|
||||
}
|
||||
|
||||
export function createJourney(userId: number, data: {
|
||||
@@ -159,7 +161,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
|
||||
// stats
|
||||
const entryCount = entries.filter(e => e.type === 'entry').length;
|
||||
const photoCount = photos.length;
|
||||
const cities = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
|
||||
const places = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
|
||||
|
||||
const userPrefs = db.prepare(
|
||||
'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
|
||||
@@ -170,7 +172,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
|
||||
entries: enrichedEntries,
|
||||
trips,
|
||||
contributors,
|
||||
stats: { entries: entryCount, photos: photoCount, cities: cities.length },
|
||||
stats: { entries: entryCount, photos: photoCount, places: places.length },
|
||||
hide_skeletons: !!(userPrefs?.hide_skeletons),
|
||||
};
|
||||
}
|
||||
@@ -184,11 +186,13 @@ export function updateJourney(journeyId: number, userId: number, data: Partial<{
|
||||
}>): Journey | null {
|
||||
if (!canEdit(journeyId, userId)) return null;
|
||||
|
||||
const ALLOWED_STATUSES = ['draft', 'active', 'completed', 'archived'];
|
||||
const allowed = ['title', 'subtitle', 'cover_gradient', 'cover_image', 'status'];
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
for (const [key, val] of Object.entries(data)) {
|
||||
if (val !== undefined && allowed.includes(key)) {
|
||||
if (key === 'status' && !ALLOWED_STATUSES.includes(val as string)) continue;
|
||||
fields.push(`${key} = ?`);
|
||||
values.push(val);
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ export function getPublicJourney(token: string) {
|
||||
const stats = {
|
||||
entries: entries.length,
|
||||
photos: photos.length,
|
||||
cities: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
|
||||
places: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
+1
-1
@@ -313,7 +313,7 @@ export interface Journey {
|
||||
subtitle?: string | null;
|
||||
cover_gradient?: string | null;
|
||||
cover_image?: string | null;
|
||||
status: 'draft' | 'active' | 'completed';
|
||||
status: 'draft' | 'active' | 'completed' | 'archived';
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ describe('listJourneys', () => {
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toBe('Road Trip');
|
||||
expect(result[0].entry_count).toBe(2);
|
||||
expect(result[0].city_count).toBe(2);
|
||||
expect(result[0].place_count).toBe(2);
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-012: includes journeys where user is contributor', () => {
|
||||
@@ -226,6 +226,21 @@ describe('listJourneys', () => {
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-013b: returns trip_date_min/max aggregated from linked trips', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id, { title: 'Multi Trip' });
|
||||
const trip1 = createTrip(testDb, user.id, { title: 'Trip A', start_date: '2025-06-01', end_date: '2025-06-10' });
|
||||
const trip2 = createTrip(testDb, user.id, { title: 'Trip B', start_date: '2026-03-15', end_date: '2026-03-20' });
|
||||
addTripToJourney(journey.id, trip1.id, user.id);
|
||||
addTripToJourney(journey.id, trip2.id, user.id);
|
||||
|
||||
const result = listJourneys(user.id);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].trip_date_min).toBe('2025-06-01');
|
||||
expect(result[0].trip_date_max).toBe('2026-03-20');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createJourney (service)', () => {
|
||||
@@ -335,6 +350,26 @@ describe('updateJourney', () => {
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.title).toBe('Same');
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-021b: accepts archived status', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id, { title: 'To Archive' });
|
||||
|
||||
const result = updateJourney(journey.id, user.id, { status: 'archived' });
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.status).toBe('archived');
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-021c: ignores invalid status value', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id, { title: 'Stay Active' });
|
||||
|
||||
const result = updateJourney(journey.id, user.id, { status: 'bogus' });
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.status).toBe('active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteJourney', () => {
|
||||
|
||||
@@ -336,7 +336,7 @@ describe('getPublicJourney', () => {
|
||||
expect(result!.entries).toHaveLength(2);
|
||||
expect(result!.stats.entries).toBe(2);
|
||||
expect(result!.stats.photos).toBe(1);
|
||||
expect(result!.stats.cities).toBe(2);
|
||||
expect(result!.stats.places).toBe(2);
|
||||
expect(result!.permissions.share_timeline).toBe(true);
|
||||
expect(result!.permissions.share_gallery).toBe(true);
|
||||
expect(result!.permissions.share_map).toBe(false);
|
||||
@@ -397,6 +397,6 @@ describe('getPublicJourney', () => {
|
||||
expect(result!.entries).toEqual([]);
|
||||
expect(result!.stats.entries).toBe(0);
|
||||
expect(result!.stats.photos).toBe(0);
|
||||
expect(result!.stats.cities).toBe(0);
|
||||
expect(result!.stats.places).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user