diff --git a/client/index.html b/client/index.html index 582bc34f..0e50cf50 100644 --- a/client/index.html +++ b/client/index.html @@ -2,7 +2,7 @@ - + TREK diff --git a/client/package-lock.json b/client/package-lock.json index f1ac488c..e099db1d 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -12,6 +12,7 @@ "axios": "^1.6.7", "leaflet": "^1.9.4", "lucide-react": "^0.344.0", + "marked": "^18.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-dropzone": "^14.4.1", @@ -1983,9 +1984,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2003,9 +2001,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2023,9 +2018,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2060,9 +2052,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2097,9 +2086,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2123,9 +2109,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2149,9 +2132,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2198,9 +2178,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2855,9 +2832,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2875,9 +2849,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2895,9 +2866,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2915,9 +2883,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2935,9 +2900,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2955,9 +2917,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3218,9 +3177,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3235,9 +3191,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3252,9 +3205,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3269,9 +3219,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3286,9 +3233,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3303,9 +3247,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3320,9 +3261,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3337,9 +3275,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3354,9 +3289,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3371,9 +3303,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3388,9 +3317,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7131,9 +7057,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7155,9 +7078,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7179,9 +7099,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7203,9 +7120,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7434,6 +7348,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/marked": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.0.tgz", + "integrity": "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/client/package.json b/client/package.json index b4533f7d..56508759 100644 --- a/client/package.json +++ b/client/package.json @@ -19,6 +19,7 @@ "axios": "^1.6.7", "leaflet": "^1.9.4", "lucide-react": "^0.344.0", + "marked": "^18.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-dropzone": "^14.4.1", diff --git a/client/src/App.tsx b/client/src/App.tsx index 0ca00b63..6adabd6a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -10,9 +10,13 @@ import AdminPage from './pages/AdminPage' import SettingsPage from './pages/SettingsPage' import VacayPage from './pages/VacayPage' import AtlasPage from './pages/AtlasPage' +import JourneyPage from './pages/JourneyPage' +import JourneyDetailPage from './pages/JourneyDetailPage' +import JourneyPublicPage from './pages/JourneyPublicPage' import SharedTripPage from './pages/SharedTripPage' import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx' import { ToastContainer } from './components/shared/Toast' +import BottomNav from './components/Layout/BottomNav' import { TranslationProvider, useTranslation } from './i18n' import { authApi } from './api/client' import { usePermissionsStore, PermissionLevel } from './store/permissionsStore' @@ -60,7 +64,12 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps return } - return <>{children} + return ( +
+
{children}
+ +
+ ) } function RootRedirect() { @@ -82,7 +91,7 @@ export default function App() { const { loadSettings } = useSettingsStore() useEffect(() => { - if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/login')) { + if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) { loadUser() } authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record }) => { @@ -162,6 +171,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> + + + + } + /> + + + + } + /> response, (error) => { if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') { - if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/')) { + if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/') && !window.location.pathname.startsWith('/public/')) { const currentPath = window.location.pathname + window.location.search window.location.href = '/login?redirect=' + encodeURIComponent(currentPath) } @@ -208,6 +208,48 @@ export const addonsApi = { enabled: () => apiClient.get('/addons').then(r => r.data), } +export const journeyApi = { + list: () => apiClient.get('/journeys').then(r => r.data), + create: (data: { title: string; subtitle?: string; trip_ids?: number[] }) => apiClient.post('/journeys', data).then(r => r.data), + get: (id: number) => apiClient.get(`/journeys/${id}`).then(r => r.data), + update: (id: number, data: Record) => apiClient.patch(`/journeys/${id}`, data).then(r => r.data), + delete: (id: number) => apiClient.delete(`/journeys/${id}`).then(r => r.data), + + suggestions: () => apiClient.get('/journeys/suggestions').then(r => r.data), + availableTrips: () => apiClient.get('/journeys/available-trips').then(r => r.data), + + // Trips (sync sources) + addTrip: (id: number, tripId: number) => apiClient.post(`/journeys/${id}/trips`, { trip_id: tripId }).then(r => r.data), + removeTrip: (id: number, tripId: number) => apiClient.delete(`/journeys/${id}/trips/${tripId}`).then(r => r.data), + + // Entries + listEntries: (id: number) => apiClient.get(`/journeys/${id}/entries`).then(r => r.data), + createEntry: (id: number, data: Record) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data), + updateEntry: (entryId: number, data: Record) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data), + deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data), + + // Photos + uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data), + addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption }).then(r => r.data), + linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).then(r => r.data), + updatePhoto: (photoId: number, data: Record) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data), + deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data), + + // Cover + uploadCover: (id: number, formData: FormData) => apiClient.post(`/journeys/${id}/cover`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data), + + // Contributors + addContributor: (id: number, userId: number, role: string) => apiClient.post(`/journeys/${id}/contributors`, { user_id: userId, role }).then(r => r.data), + updateContributor: (id: number, userId: number, role: string) => apiClient.patch(`/journeys/${id}/contributors/${userId}`, { role }).then(r => r.data), + removeContributor: (id: number, userId: number) => apiClient.delete(`/journeys/${id}/contributors/${userId}`).then(r => r.data), + + // Share + getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data), + createShareLink: (id: number, perms: { share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean }) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data), + deleteShareLink: (id: number) => apiClient.delete(`/journeys/${id}/share-link`).then(r => r.data), + getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data), +} + export const mapsApi = { search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data), details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data), diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index b45f9f00..5d9f7887 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -4,10 +4,10 @@ import { useTranslation } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' import { useAddonStore } from '../../store/addonStore' import { useToast } from '../shared/Toast' -import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2 } from 'lucide-react' +import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass } from 'lucide-react' const ICON_MAP = { - ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, + ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, } interface Addon { diff --git a/client/src/components/Collab/CollabChat.tsx b/client/src/components/Collab/CollabChat.tsx index 251a4439..ab6779c0 100644 --- a/client/src/components/Collab/CollabChat.tsx +++ b/client/src/components/Collab/CollabChat.tsx @@ -762,7 +762,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) { )} {/* Composer */} -
+
{/* Reply preview */} {replyTo && (
+

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + p: ({ children }) =>

{children}

, + blockquote: ({ children }) => ( +
{children}
+ ), + a: ({ href, children }) => ( + + {children} + + ), + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + strong: ({ children }) => {children}, + em: ({ children }) => {children}, + hr: () =>
    , + code: ({ children, className }) => { + const isBlock = className?.includes('language-') + if (isBlock) { + return ( +
    +                  {children}
    +                
    + ) + } + return ( + {children} + ) + }, + }} + > + {text} +
    +
    + ) +} diff --git a/client/src/components/Journey/JourneyMap.tsx b/client/src/components/Journey/JourneyMap.tsx new file mode 100644 index 00000000..f8a0d58a --- /dev/null +++ b/client/src/components/Journey/JourneyMap.tsx @@ -0,0 +1,299 @@ +import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react' +import L from 'leaflet' +import { useSettingsStore } from '../../store/settingsStore' + +export interface MapMarkerItem { + id: string + lat: number + lng: number + label: string + mood?: string | null + time: string +} + +export interface JourneyMapHandle { + highlightMarker: (id: string | null) => void + focusMarker: (id: string) => void +} + +interface MapEntry { + id: string + lat: number + lng: number + title?: string | null + mood?: string | null + entry_date: string +} + +interface Props { + checkins: any[] + entries: MapEntry[] + trail?: { lat: number; lng: number }[] + height?: number + dark?: boolean + activeMarkerId?: string | null + onMarkerClick?: (id: string, type?: string) => void +} + +function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] { + const items: MapMarkerItem[] = [] + for (const e of entries) { + if (e.lat && e.lng) { + items.push({ + id: e.id, + lat: e.lat, + lng: e.lng, + label: e.title || 'Entry', + mood: e.mood, + time: e.entry_date, + }) + } + } + items.sort((a, b) => a.time.localeCompare(b.time)) + return items +} + +const MARKER_W = 28 +const MARKER_H = 36 + +function markerSvg(index: number, highlighted: boolean, dark: boolean): string { + const fill = dark + ? (highlighted ? '#FAFAFA' : '#FAFAFA') + : (highlighted ? '#18181B' : '#18181B') + const textColor = dark + ? (highlighted ? '#18181B' : '#18181B') + : (highlighted ? '#fff' : '#fff') + const stroke = dark ? '#3F3F46' : '#fff' + const shadow = highlighted + ? 'filter:drop-shadow(0 0 8px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))' + : 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))' + const label = String(index + 1) + const scale = highlighted ? 1.2 : 1 + + return `
    + + + + ${label} + +
    ` +} + +const EMPTY_TRAIL: { lat: number; lng: number }[] = [] + +const JourneyMap = forwardRef(function JourneyMap( + { entries, trail, height = 220, dark, activeMarkerId, onMarkerClick }, + ref +) { + const stableTrail = trail || EMPTY_TRAIL + const mapTileUrl = useSettingsStore(s => s.settings.map_tile_url) + const containerRef = useRef(null) + const mapRef = useRef(null) + const markersRef = useRef>(new Map()) + const itemsRef = useRef([]) + const highlightedRef = useRef(null) + const onMarkerClickRef = useRef(onMarkerClick) + onMarkerClickRef.current = onMarkerClick + + const darkRef = useRef(dark) + darkRef.current = dark + + const highlightMarker = useCallback((id: string | null) => { + const prev = highlightedRef.current + highlightedRef.current = id + const isDark = !!darkRef.current + + if (prev && prev !== id) { + const marker = markersRef.current.get(prev) + const item = itemsRef.current.find(i => i.id === prev) + if (marker && item) { + const idx = itemsRef.current.indexOf(item) + marker.setIcon(L.divIcon({ + className: '', + iconSize: [MARKER_W, MARKER_H], + iconAnchor: [MARKER_W / 2, MARKER_H], + html: markerSvg(idx, false, isDark), + })) + marker.setZIndexOffset(0) + } + } + + if (id) { + const marker = markersRef.current.get(id) + const item = itemsRef.current.find(i => i.id === id) + if (marker && item) { + const idx = itemsRef.current.indexOf(item) + marker.setIcon(L.divIcon({ + className: '', + iconSize: [MARKER_W, MARKER_H], + iconAnchor: [MARKER_W / 2, MARKER_H], + html: markerSvg(idx, true, isDark), + })) + marker.setZIndexOffset(1000) + } + } + }, []) + + const focusMarker = useCallback((id: string) => { + highlightMarker(id) + const marker = markersRef.current.get(id) + if (marker && mapRef.current) { + mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 }) + } + }, []) + + useImperativeHandle(ref, () => ({ highlightMarker, focusMarker }), []) + + useEffect(() => { + if (!containerRef.current) return + + if (mapRef.current) { + mapRef.current.remove() + mapRef.current = null + } + markersRef.current.clear() + + const map = L.map(containerRef.current, { + zoomControl: false, + attributionControl: false, + scrollWheelZoom: false, + dragging: true, + touchZoom: true, + }) + mapRef.current = map + + const defaultTile = dark + ? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' + : 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png' + L.tileLayer(mapTileUrl || defaultTile, { maxZoom: 18 }).addTo(map) + + const items = buildMarkerItems(entries) + itemsRef.current = items + + const allCoords: L.LatLngTuple[] = [] + + if (stableTrail.length > 1) { + const coords = stableTrail.map(p => [p.lat, p.lng] as L.LatLngTuple) + L.polyline(coords, { + color: '#6366f1', weight: 3, opacity: 0.4, + dashArray: '6 4', lineCap: 'round', + }).addTo(map) + coords.forEach(c => allCoords.push(c)) + } + + // route polyline — subtle dashed connection + if (items.length > 1) { + const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple) + L.polyline(routeCoords, { + color: dark ? '#71717A' : '#A1A1AA', + weight: 1.5, + opacity: 0.5, + dashArray: '4 6', + lineCap: 'round', lineJoin: 'round', + }).addTo(map) + } + + // place markers + items.forEach((item, i) => { + const pos: L.LatLngTuple = [item.lat, item.lng] + allCoords.push(pos) + + const icon = L.divIcon({ + className: '', + iconSize: [MARKER_W, MARKER_H], + iconAnchor: [MARKER_W / 2, MARKER_H], + html: markerSvg(i, false, !!dark), + }) + + const marker = L.marker(pos, { icon }).addTo(map) + marker.bindTooltip(item.label, { + direction: 'top', + offset: [0, -MARKER_H], + className: 'map-tooltip', + }) + + marker.on('click', () => { + onMarkerClickRef.current?.(item.id) + }) + + markersRef.current.set(item.id, marker) + }) + + // fit bounds + requestAnimationFrame(() => { + if (!mapRef.current) return + try { + map.invalidateSize() + if (allCoords.length > 0) { + map.fitBounds(L.latLngBounds(allCoords), { padding: [50, 50], maxZoom: 14 }) + } else { + map.setView([30, 0], 2) + } + } catch {} + }) + + setTimeout(() => { + if (mapRef.current) map.invalidateSize() + }, 200) + + return () => { + map.remove() + mapRef.current = null + markersRef.current.clear() + } + }, [entries, stableTrail, dark, mapTileUrl]) + + // react to activeMarkerId prop changes — runs after map is built + useEffect(() => { + if (!activeMarkerId || !mapRef.current) return + // small delay to ensure markers are rendered after map build + const timer = setTimeout(() => { + highlightMarker(activeMarkerId) + const marker = markersRef.current.get(activeMarkerId) + if (marker && mapRef.current) { + mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 }) + } + }, 50) + return () => clearTimeout(timer) + }, [activeMarkerId]) + + const zoomIn = () => mapRef.current?.zoomIn() + const zoomOut = () => mapRef.current?.zoomOut() + + return ( +
    +
    +
    + + +
    +
    + ) +}) + +export default JourneyMap diff --git a/client/src/components/Journey/MarkdownToolbar.tsx b/client/src/components/Journey/MarkdownToolbar.tsx new file mode 100644 index 00000000..6a82cadb --- /dev/null +++ b/client/src/components/Journey/MarkdownToolbar.tsx @@ -0,0 +1,81 @@ +import { Bold, Italic, Heading2, Link, Quote, List, ListOrdered, Minus } from 'lucide-react' + +interface Props { + textareaRef: React.RefObject + onUpdate: (value: string) => void + dark?: boolean +} + +type FormatAction = { type: 'wrap'; before: string; after: string } | { type: 'line'; prefix: string } + +const ACTIONS: Array<{ icon: typeof Bold; label: string; action: FormatAction }> = [ + { icon: Bold, label: 'Bold', action: { type: 'wrap', before: '**', after: '**' } }, + { icon: Italic, label: 'Italic', action: { type: 'wrap', before: '_', after: '_' } }, + { icon: Heading2, label: 'Heading', action: { type: 'line', prefix: '## ' } }, + { icon: Quote, label: 'Quote', action: { type: 'line', prefix: '> ' } }, + { icon: Link, label: 'Link', action: { type: 'wrap', before: '[', after: '](url)' } }, + { icon: List, label: 'List', action: { type: 'line', prefix: '- ' } }, + { icon: ListOrdered, label: 'Ordered', action: { type: 'line', prefix: '1. ' } }, + { icon: Minus, label: 'Divider', action: { type: 'line', prefix: '\n---\n' } }, +] + +export default function MarkdownToolbar({ textareaRef, onUpdate, dark }: Props) { + const apply = (action: FormatAction) => { + const ta = textareaRef.current + if (!ta) return + + const start = ta.selectionStart + const end = ta.selectionEnd + const text = ta.value + const selected = text.slice(start, end) + + let result: string + let cursorPos: number + + if (action.type === 'wrap') { + result = text.slice(0, start) + action.before + selected + action.after + text.slice(end) + cursorPos = selected ? end + action.before.length + action.after.length : start + action.before.length + } else { + // line prefix — find start of current line + const lineStart = text.lastIndexOf('\n', start - 1) + 1 + result = text.slice(0, lineStart) + action.prefix + text.slice(lineStart) + cursorPos = start + action.prefix.length + } + + onUpdate(result) + + // restore cursor after React re-render + requestAnimationFrame(() => { + ta.focus() + ta.setSelectionRange(cursorPos, cursorPos) + }) + } + + return ( +
    + {ACTIONS.map(a => ( + + ))} +
    + ) +} diff --git a/client/src/components/Journey/PhotoLightbox.tsx b/client/src/components/Journey/PhotoLightbox.tsx new file mode 100644 index 00000000..0bb5225d --- /dev/null +++ b/client/src/components/Journey/PhotoLightbox.tsx @@ -0,0 +1,210 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { ChevronLeft, ChevronRight, X, Camera, Aperture } from 'lucide-react' +import apiClient from '../../api/client' + +interface LightboxPhoto { + id: string + src: string + caption?: string | null + provider?: string + asset_id?: string | null + owner_id?: number | null +} + +interface ExifData { + camera?: string + lens?: string + focalLength?: string + aperture?: string + shutter?: string + iso?: number + fileName?: string +} + +interface Props { + photos: LightboxPhoto[] + startIndex?: number + onClose: () => void +} + +export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props) { + const [idx, setIdx] = useState(startIndex) + const [exif, setExif] = useState(null) + const [exifLoading, setExifLoading] = useState(false) + const touchStart = useRef<{ x: number; y: number } | null>(null) + + const photo = photos[idx] + const hasPrev = idx > 0 + const hasNext = idx < photos.length - 1 + + const prev = useCallback(() => { if (hasPrev) setIdx(i => i - 1) }, [hasPrev]) + const next = useCallback(() => { if (hasNext) setIdx(i => i + 1) }, [hasNext]) + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + if (e.key === 'ArrowLeft') prev() + if (e.key === 'ArrowRight') next() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [prev, next, onClose]) + + // Fetch EXIF data for Immich photos + useEffect(() => { + setExif(null) + if (!photo || photo.provider !== 'immich' || !photo.asset_id || !photo.owner_id) return + let cancelled = false + setExifLoading(true) + apiClient.get(`/integrations/memories/immich/assets/0/${photo.asset_id}/${photo.owner_id}/info`) + .then(r => { + if (!cancelled && r.data) { + const d = r.data + const parts: Partial = {} + if (d.camera && d.camera.trim() && d.camera !== 'undefined undefined') parts.camera = d.camera + if (d.lens) parts.lens = d.lens + if (d.focalLength) parts.focalLength = d.focalLength + if (d.aperture) parts.aperture = d.aperture + if (d.shutter) parts.shutter = d.shutter + if (d.iso) parts.iso = d.iso + if (d.fileName) parts.fileName = d.fileName + if (Object.keys(parts).length > 0) setExif(parts) + } + }) + .catch(() => {}) + .finally(() => { if (!cancelled) setExifLoading(false) }) + return () => { cancelled = true } + }, [photo]) + + const onTouchStart = (e: React.TouchEvent) => { + const t = e.touches[0] + touchStart.current = { x: t.clientX, y: t.clientY } + } + + const onTouchEnd = (e: React.TouchEvent) => { + if (!touchStart.current) return + const t = e.changedTouches[0] + const dx = t.clientX - touchStart.current.x + const dy = t.clientY - touchStart.current.y + + // swipe down to close + if (dy > 80 && Math.abs(dx) < 60) { + onClose() + return + } + // horizontal swipe + if (Math.abs(dx) > 50 && Math.abs(dy) < 80) { + if (dx < 0) next() + else prev() + } + touchStart.current = null + } + + if (!photo) return null + + return ( +
    + {/* Top bar */} +
    + + {idx + 1} / {photos.length} + + +
    + + {/* Photo */} +
    + {hasPrev && ( + + )} + +
    + {photo.caption + + {/* EXIF metadata overlay */} + {exif && !exifLoading && ( +
    + {exif.camera && ( +
    + + {exif.camera} +
    + )} + {exif.lens && ( +
    {exif.lens}
    + )} + {(exif.focalLength || exif.aperture || exif.shutter || exif.iso) && ( +
    + + + {[exif.focalLength, exif.aperture, exif.shutter, exif.iso ? `ISO ${exif.iso}` : ''].filter(Boolean).join(' · ')} + +
    + )} +
    + )} +
    + + {hasNext && ( + + )} +
    + + {/* Caption */} + {photo.caption && ( +
    +

    {photo.caption}

    +
    + )} +
    + ) +} diff --git a/client/src/components/Journey/moodConfig.ts b/client/src/components/Journey/moodConfig.ts new file mode 100644 index 00000000..738457db --- /dev/null +++ b/client/src/components/Journey/moodConfig.ts @@ -0,0 +1,65 @@ +import { Sparkles, Sun, Minus, Moon, CloudRain, CloudSun, Cloud, CloudLightning, Snowflake, Thermometer, ThermometerSnowflake } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' + +export interface MoodDef { + id: string + label: string + icon: LucideIcon + color: string + cssVar: string +} + +export const MOODS: MoodDef[] = [ + { id: 'amazing', label: 'Amazing', icon: Sparkles, color: '#E8654A', cssVar: 'var(--mood-amazing)' }, + { id: 'good', label: 'Good', icon: Sun, color: '#EF9F27', cssVar: 'var(--mood-good)' }, + { id: 'neutral', label: 'Neutral', icon: Minus, color: '#94928C', cssVar: 'var(--mood-neutral)' }, + { id: 'tired', label: 'Tired', icon: Moon, color: '#6B9BD2', cssVar: 'var(--mood-tired)' }, + { id: 'rough', label: 'Rough', icon: CloudRain,color: '#9B8EC4', cssVar: 'var(--mood-rough)' }, +] + +export const MOOD_DEFAULT_COLOR = '#D4D4D4' + +export function getMood(id: string | null | undefined): MoodDef | undefined { + if (!id) return undefined + return MOODS.find(m => m.id === id) +} + +export function moodColor(id: string | null | undefined): string { + return getMood(id)?.cssVar || 'var(--journal-faint)' +} + +export interface WeatherDef { + id: string + label: string + icon: LucideIcon +} + +export const WEATHERS: WeatherDef[] = [ + { id: 'sunny', label: 'Sunny', icon: Sun }, + { id: 'partly', label: 'Partly cloudy', icon: CloudSun }, + { id: 'cloudy', label: 'Cloudy', icon: Cloud }, + { id: 'rainy', label: 'Rainy', icon: CloudRain }, + { id: 'stormy', label: 'Stormy', icon: CloudLightning }, + { id: 'snowy', label: 'Snowy', icon: Snowflake }, + { id: 'hot', label: 'Hot', icon: Thermometer }, + { id: 'cold', label: 'Cold', icon: ThermometerSnowflake }, +] + +export function getWeather(id: string | null | undefined): WeatherDef | undefined { + if (!id) return undefined + return WEATHERS.find(w => w.id === id) +} + +export const TAG_STYLES: Record = { + 'hidden gem': { bg: '#dcfce7', fg: '#166534', darkBg: 'rgba(22,101,52,0.2)', darkFg: '#86efac' }, + 'must revisit': { bg: '#dbeafe', fg: '#1e40af', darkBg: 'rgba(30,64,175,0.2)', darkFg: '#93c5fd' }, + 'best meal': { bg: '#fef3c7', fg: '#92400e', darkBg: 'rgba(146,64,14,0.2)', darkFg: '#fcd34d' }, + 'tourist trap': { bg: '#fee2e2', fg: '#991b1b', darkBg: 'rgba(153,27,27,0.2)', darkFg: '#fca5a5' }, + 'disaster': { bg: '#fce4ec', fg: '#880e4f', darkBg: 'rgba(136,14,79,0.2)', darkFg: '#f48fb1' }, +} + +export function tagColors(tag: string, dark: boolean) { + const known = TAG_STYLES[tag.toLowerCase()] + if (known) return { bg: dark ? known.darkBg : known.bg, fg: dark ? known.darkFg : known.fg } + return { bg: dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.05)', fg: dark ? '#a1a1aa' : '#374151' } +} diff --git a/client/src/components/Journey/stripMarkdown.ts b/client/src/components/Journey/stripMarkdown.ts new file mode 100644 index 00000000..f600a9b2 --- /dev/null +++ b/client/src/components/Journey/stripMarkdown.ts @@ -0,0 +1,24 @@ +/** + * Strip markdown formatting to get plain text for previews. + * Handles: bold, italic, headings, links, images, blockquotes, code, lists, hr. + */ +export function stripMarkdown(md: string): string { + return md + .replace(/^#{1,6}\s+/gm, '') // headings + .replace(/!\[.*?\]\(.*?\)/g, '') // images + .replace(/\[([^\]]*)\]\(.*?\)/g, '$1') // links → text + .replace(/(`{3}[\s\S]*?`{3})/g, '') // code blocks + .replace(/`([^`]+)`/g, '$1') // inline code + .replace(/\*\*(.+?)\*\*/g, '$1') // bold ** + .replace(/__(.+?)__/g, '$1') // bold __ + .replace(/\*(.+?)\*/g, '$1') // italic * + .replace(/_(.+?)_/g, '$1') // italic _ + .replace(/~~(.+?)~~/g, '$1') // strikethrough + .replace(/^>\s?/gm, '') // blockquotes + .replace(/^[-*+]\s+/gm, '') // unordered lists + .replace(/^\d+\.\s+/gm, '') // ordered lists + .replace(/^---+$/gm, '') // horizontal rules + .replace(/\n{2,}/g, ' ') // collapse multiple newlines + .replace(/\n/g, ' ') // remaining newlines → spaces + .trim() +} diff --git a/client/src/components/Layout/BottomNav.tsx b/client/src/components/Layout/BottomNav.tsx new file mode 100644 index 00000000..95c552ef --- /dev/null +++ b/client/src/components/Layout/BottomNav.tsx @@ -0,0 +1,161 @@ +import { useState } from 'react' +import { NavLink, useNavigate } from 'react-router-dom' +import { useAddonStore } from '../../store/addonStore' +import { useAuthStore } from '../../store/authStore' +import { useTranslation } from '../../i18n' +import { Plane, CalendarDays, Globe, Compass, User, Settings, Shield, LogOut, X } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' + +const BASE_ITEMS: { to: string; label: string; icon: LucideIcon; addonId?: string }[] = [ + { to: '/trips', label: 'Trips', icon: Plane }, +] + +const ADDON_NAV: Record = { + vacay: { to: '/vacay', label: 'Vacay', icon: CalendarDays }, + atlas: { to: '/atlas', label: 'Atlas', icon: Globe }, + journey: { to: '/journey', label: 'Journey', icon: Compass }, +} + +export default function BottomNav() { + const { t } = useTranslation() + const addons = useAddonStore(s => s.addons) + const globalAddons = addons.filter(a => a.type === 'global' && a.enabled) + const [showProfile, setShowProfile] = useState(false) + + const items = [...BASE_ITEMS] + for (const addon of globalAddons) { + const nav = ADDON_NAV[addon.id] + if (nav) items.push(nav) + } + + return ( + <> + + + {showProfile && setShowProfile(false)} />} + + ) +} + +function ProfileSheet({ onClose }: { onClose: () => void }) { + const { t } = useTranslation() + const { user, logout } = useAuthStore() + const navigate = useNavigate() + + const handleNav = (path: string) => { + onClose() + navigate(path) + } + + const handleLogout = () => { + onClose() + logout() + navigate('/login') + } + + return ( +
    + {/* Backdrop */} +
    + + {/* Sheet */} +
    e.stopPropagation()} + > + {/* Handle */} +
    +
    +
    + + {/* User info */} +
    +
    +
    + {(user?.username || '?')[0].toUpperCase()} +
    +
    +

    {user?.username}

    +

    {user?.email}

    +
    + {user?.role === 'admin' && ( + + Admin + + )} +
    +
    + +
    + + {/* Links */} +
    + + + {user?.role === 'admin' && ( + + )} +
    + +
    + + {/* Logout */} +
    + +
    + +
    +
    +
    + ) +} diff --git a/client/src/components/Layout/MobileTopHeader.tsx b/client/src/components/Layout/MobileTopHeader.tsx new file mode 100644 index 00000000..4f6f3052 --- /dev/null +++ b/client/src/components/Layout/MobileTopHeader.tsx @@ -0,0 +1,17 @@ +interface Props { + title: string + subtitle?: string + actions?: React.ReactNode +} + +export default function MobileTopHeader({ title, subtitle, actions }: Props) { + return ( +
    +
    +

    {title}

    + {subtitle &&
    {subtitle}
    } +
    + {actions &&
    {actions}
    } +
    + ) +} diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx index cee59b8b..9fc11f17 100644 --- a/client/src/components/Layout/Navbar.tsx +++ b/client/src/components/Layout/Navbar.tsx @@ -5,11 +5,11 @@ import { useAuthStore } from '../../store/authStore' import { useSettingsStore } from '../../store/settingsStore' import { useAddonStore } from '../../store/addonStore' import { useTranslation } from '../../i18n' -import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe } from 'lucide-react' +import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import InAppNotificationBell from './InAppNotificationBell.tsx' -const ADDON_ICONS: Record = { CalendarDays, Briefcase, Globe } +const ADDON_ICONS: Record = { CalendarDays, Briefcase, Globe, Compass } interface NavbarProps { tripTitle?: string @@ -75,7 +75,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: touchAction: 'manipulation', paddingTop: 'env(safe-area-inset-top, 0px)', height: 'var(--nav-h)', - }} className="flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]"> + }} className="hidden md:flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]"> {/* Left side */}
    {showBack && ( diff --git a/client/src/components/PDF/JourneyBookPDF.tsx b/client/src/components/PDF/JourneyBookPDF.tsx new file mode 100644 index 00000000..dfd07348 --- /dev/null +++ b/client/src/components/PDF/JourneyBookPDF.tsx @@ -0,0 +1,307 @@ +// Journey Photo Book PDF — Polarsteps-inspired, magazine-density +import { marked } from 'marked' +import type { JourneyDetail, JourneyEntry, JourneyPhoto } from '../../store/journeyStore' + +function esc(str: string | null | undefined): string { + if (!str) return '' + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} + +function md(str: string | null | undefined): string { + if (!str) return '' + return marked.parse(str, { async: false, breaks: true }) as string +} + +function abs(url: string | null | undefined): string { + if (!url) return '' + if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url + return window.location.origin + (url.startsWith('/') ? '' : '/') + url +} + +function pSrc(p: JourneyPhoto): string { + if (p.provider === 'local') return abs(`/uploads/${p.file_path}`) + return abs(`/api/integrations/memories/${p.provider}/assets/0/${p.asset_id}/${p.owner_id}/original`) +} + +function fmtDate(d: string): string { + const date = new Date(d + 'T00:00:00') + return date.toLocaleDateString('en', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }) +} + +function fmtShort(d: string): string { + return new Date(d + 'T00:00:00').toLocaleDateString('en', { month: 'short', day: 'numeric' }) +} + +function groupByDate(entries: JourneyEntry[]): Map { + const groups = new Map() + for (const e of entries) { + if (!e.entry_date) continue + if (!groups.has(e.entry_date)) groups.set(e.entry_date, []) + groups.get(e.entry_date)!.push(e) + } + return groups +} + +function renderProscons(entry: JourneyEntry): string { + const pc = entry.pros_cons + if (!pc) return '' + const pros = pc.pros?.filter(p => p.trim()) || [] + const cons = pc.cons?.filter(c => c.trim()) || [] + if (pros.length === 0 && cons.length === 0) return '' + + return `
    + ${pros.length > 0 ? `
    Loved it
      ${pros.map(p => `
    • ${esc(p)}
    • `).join('')}
    ` : ''} + ${cons.length > 0 ? `
    Could be better
      ${cons.map(c => `
    • ${esc(c)}
    • `).join('')}
    ` : ''} +
    ` +} + +function renderPhotoBlock(photos: JourneyPhoto[]): string { + if (photos.length === 0) return '' + if (photos.length === 1) { + return `
    ` + } + if (photos.length === 2) { + return `
    ${photos.map(p => `
    `).join('')}
    ` + } + // 3+ photos: hero left + stack right + return `
    +
    +
    +
    +
    +
    +
    ` +} + +export async function downloadJourneyBookPDF(journey: JourneyDetail) { + const entries = (journey.entries || []).filter(e => e.type !== 'skeleton' && e.type !== 'gallery') + const allPhotos = entries.flatMap(e => e.photos || []) + const coverUrl = journey.cover_image ? abs(`/uploads/${journey.cover_image}`) : (allPhotos[0] ? pSrc(allPhotos[0]) : '') + + const grouped = groupByDate(entries) + const dates = [...grouped.keys()].sort() + + // Build entry pages — one per entry, day header inline on first entry of day + const entryPages: string[] = [] + let pageNum = 1 // cover=1 + dates.forEach((date, di) => { + const dayEntries = grouped.get(date)! + dayEntries.forEach((entry, ei) => { + pageNum++ + const isFirstOfDay = ei === 0 + const photos = entry.photos || [] + const meta = [entry.entry_time, entry.location_name].filter(Boolean).join(' · ') + + // Day header (inline, only on first entry of day) + const dayHeaderHtml = isFirstOfDay + ? `
    Day ${di + 1} · ${fmtDate(date)}
    ` + : '' + + // Photo block + const photoHtml = renderPhotoBlock(photos) + + // Pros/cons + const prosconsHtml = renderProscons(entry) + + // Story (markdown) + const storyHtml = entry.story ? `
    ${md(entry.story)}
    ` : '' + + entryPages.push(` +
    + ${dayHeaderHtml} + ${photoHtml} +
    + ${meta ? `` : ''} + ${entry.title ? `

    ${esc(entry.title)}

    ` : ''} + ${storyHtml} + ${prosconsHtml} +
    +
    + `) + }) + }) + + const totalPages = pageNum + 1 // +1 for closing page + + const html = ` + + + + +${esc(journey.title)} — Journey Book + + + + + + + +
    + ${coverUrl ? `
    ` : ''} +
    +
    +
    +
    Journey Book
    +

    ${esc(journey.title)}

    + ${journey.subtitle ? `
    ${esc(journey.subtitle)}
    ` : ''} +
    +
    ${dates.length}
    Days
    +
    ${entries.length}
    Entries
    +
    ${allPhotos.length}
    Photos
    +
    +
    + +
    + + + ${entryPages.join('\n')} + + +
    +
    +
    The End
    +
    Made with TREK · ${new Date().getFullYear()}
    +
    +
    + + +` + + const win = window.open('', '_blank') + if (!win) return + win.document.write(html) + win.document.close() +} diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index e841abde..827ae454 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -167,7 +167,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" } return ( -
    +
    void + onAddNew?: () => void +}) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + + // Find places not assigned to this day + const assignedToDay = new Set((assignments[String(dayId)] || []).map(a => a.place_id)) + const available = places.filter(p => !assignedToDay.has(p.id)) + const filtered = search.trim() + ? available.filter(p => p.name.toLowerCase().includes(search.toLowerCase())) + : available + + return ( +
    + {!open ? ( + + ) : ( +
    +
    + setSearch(e.target.value)} + placeholder={t('dayplan.mobile.searchPlaces')} + style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 13, fontFamily: 'inherit', color: 'var(--text-primary)' }} + /> + +
    +
    + {filtered.length === 0 && ( +
    + {available.length === 0 ? t('dayplan.mobile.allAssigned') : t('dayplan.mobile.noMatch')} +
    + )} + {filtered.slice(0, 20).map(p => ( + + ))} +
    + {onAddNew && ( + + )} +
    + )} +
    + ) +} + interface DayPlanSidebarProps { tripId: number trip: Trip @@ -79,6 +172,8 @@ interface DayPlanSidebarProps { reservations?: Reservation[] onAddReservation: () => void onNavigateToFiles?: () => void + onAddPlace?: () => void + onAddPlaceToDay?: (placeId: number, dayId: number) => void onExpandedDaysChange?: (expandedDayIds: Set) => void pushUndo?: (label: string, undoFn: () => Promise | void) => void canUndo?: boolean @@ -95,6 +190,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace, reservations = [], onAddReservation, + onAddPlace, + onAddPlaceToDay, onNavigateToFiles, onExpandedDaysChange, pushUndo, @@ -1623,6 +1720,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
    )} + + {/* Mobile: Add Place from list */} +
    )}
    diff --git a/client/src/components/Trips/TripFormModal.tsx b/client/src/components/Trips/TripFormModal.tsx index 76431059..4b2865ce 100644 --- a/client/src/components/Trips/TripFormModal.tsx +++ b/client/src/components/Trips/TripFormModal.tsx @@ -46,6 +46,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp const [uploadingCover, setUploadingCover] = useState(false) const [allUsers, setAllUsers] = useState<{ id: number; username: string }[]>([]) const [selectedMembers, setSelectedMembers] = useState([]) + const [existingMembers, setExistingMembers] = useState<{ id: number; username: string }[]>([]) const [memberSelectValue, setMemberSelectValue] = useState('') useEffect(() => { @@ -74,8 +75,11 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled) }).catch(() => {}) } - if (!trip) { - authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {}) + authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {}) + if (trip) { + tripsApi.getMembers(trip.id).then(d => setExistingMembers(d.members || [])).catch(() => {}) + } else { + setExistingMembers([]) } }, [trip, isOpen]) @@ -365,12 +369,38 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
    )} - {/* Members — only for new trips */} - {!isEditing && allUsers.filter(u => u.id !== currentUser?.id).length > 0 && ( + {/* Members */} + {allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
    + {/* Existing members (editing mode) */} + {isEditing && existingMembers.length > 0 && ( +
    + {existingMembers.map(m => ( + { + if (m.id === currentUser?.id) return + try { + await tripsApi.removeMember(trip!.id, m.id) + setExistingMembers(prev => prev.filter(x => x.id !== m.id)) + toast.success(`${m.username} removed`) + } catch { toast.error('Failed to remove') } + }} + style={{ + display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px', borderRadius: 99, + background: 'var(--bg-secondary)', fontSize: 12, fontWeight: 500, color: 'var(--text-primary)', + cursor: m.id === currentUser?.id ? 'default' : 'pointer', + border: '1px solid var(--border-primary)', + }}> + {m.username} + {m.id !== currentUser?.id && } + + ))} +
    + )} + {/* Newly selected members (both modes) */} {selectedMembers.length > 0 && (
    {selectedMembers.map(uid => { @@ -393,11 +423,24 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
    { - if (value) { setSelectedMembers(prev => prev.includes(Number(value)) ? prev : [...prev, Number(value)]); setMemberSelectValue('') } + onChange={async value => { + if (!value) return + if (isEditing && trip?.id) { + const user = allUsers.find(u => u.id === Number(value)) + if (user) { + try { + await tripsApi.addMember(trip.id, user.username) + setExistingMembers(prev => [...prev, { id: user.id, username: user.username }]) + toast.success(`${user.username} added`) + } catch { toast.error('Failed to add') } + } + } else { + setSelectedMembers(prev => prev.includes(Number(value)) ? prev : [...prev, Number(value)]) + } + setMemberSelectValue('') }} placeholder={t('dashboard.addMember')} - options={allUsers.filter(u => u.id !== currentUser?.id && !selectedMembers.includes(u.id)).map(u => ({ value: u.id, label: u.username }))} + options={allUsers.filter(u => u.id !== currentUser?.id && !selectedMembers.includes(u.id) && !existingMembers.some(m => m.id === u.id)).map(u => ({ value: u.id, label: u.username }))} searchable size="sm" /> diff --git a/client/src/components/Vacay/VacayCalendar.tsx b/client/src/components/Vacay/VacayCalendar.tsx index 15536df5..d9fed48b 100644 --- a/client/src/components/Vacay/VacayCalendar.tsx +++ b/client/src/components/Vacay/VacayCalendar.tsx @@ -1,7 +1,8 @@ -import { useMemo, useState, useCallback } from 'react' +import { useMemo, useState, useCallback, useEffect } from 'react' import { useVacayStore } from '../../store/vacayStore' import { useTranslation } from '../../i18n' import { isWeekend } from './holidays' +import { tripsApi } from '../../api/client' import VacayMonthCard from './VacayMonthCard' import { Building2, MousePointer2 } from 'lucide-react' @@ -9,6 +10,30 @@ export default function VacayCalendar() { const { t } = useTranslation() const { selectedYear, selectedUserId, entries, companyHolidays, toggleEntry, toggleCompanyHoliday, plan, users, holidays } = useVacayStore() const [companyMode, setCompanyMode] = useState(false) + const [tripDates, setTripDates] = useState>(new Set()) + + useEffect(() => { + let cancelled = false + ;(async () => { + try { + const data = await tripsApi.list() + const dates = new Set() + for (const trip of data.trips || []) { + if (!trip.start_date || !trip.end_date) continue + const start = new Date(trip.start_date + 'T00:00:00') + const end = new Date(trip.end_date + 'T00:00:00') + for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { + const y = d.getFullYear() + if (y === selectedYear) { + dates.add(`${y}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`) + } + } + } + if (!cancelled) setTripDates(dates) + } catch { /* ignore */ } + })() + return () => { cancelled = true } + }, [selectedYear]) const companyHolidaySet = useMemo(() => { const s = new Set() @@ -59,6 +84,7 @@ export default function VacayCalendar() { companyMode={companyMode} blockWeekends={blockWeekends} weekendDays={weekendDays} + tripDates={tripDates} /> ))}
    diff --git a/client/src/components/Vacay/VacayMonthCard.tsx b/client/src/components/Vacay/VacayMonthCard.tsx index 1e639580..1e3bd78b 100644 --- a/client/src/components/Vacay/VacayMonthCard.tsx +++ b/client/src/components/Vacay/VacayMonthCard.tsx @@ -23,11 +23,12 @@ interface VacayMonthCardProps { companyMode: boolean blockWeekends: boolean weekendDays?: number[] + tripDates?: Set } export default function VacayMonthCard({ year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap, - onCellClick, companyMode, blockWeekends, weekendDays = [0, 6] + onCellClick, companyMode, blockWeekends, weekendDays = [0, 6], tripDates }: VacayMonthCardProps) { const { t, locale } = useTranslation() @@ -122,6 +123,10 @@ export default function VacayMonthCard({
    )} + {tripDates?.has(dateStr) && ( + + )} + 0 ? 700 : 500, diff --git a/client/src/components/shared/Modal.tsx b/client/src/components/shared/Modal.tsx index 5b8124b2..470ce316 100644 --- a/client/src/components/shared/Modal.tsx +++ b/client/src/components/shared/Modal.tsx @@ -61,7 +61,7 @@ export default function Modal({
    = { 'notif.generic.text': 'Você tem uma nova notificação', 'notif.dev.unknown_event.title': '[DEV] Evento desconhecido', 'notif.dev.unknown_event.text': 'O tipo de evento "{event}" não está registrado em EVENT_NOTIFICATION_CONFIG', + + // Journey, Dashboard, Nav, DayPlan + 'common.justNow': 'agora mesmo', + 'common.hoursAgo': 'há {count}h', + 'common.daysAgo': 'há {count}d', + 'budget.linkedToReservation': 'Vinculado a uma reserva — edite o nome lá', + 'packing.saveAsTemplate': 'Salvar como modelo', + 'packing.templateName': 'Nome do modelo', + 'packing.templateSaved': 'Lista de bagagem salva como modelo', + 'memories.notConnectedMultipleHint': 'Conecte qualquer um destes provedores de fotos: {provider_names} em Configurações para poder adicionar fotos a esta viagem.', + 'memories.providerUrl': 'URL do servidor', + 'memories.providerApiKey': 'Chave da API', + 'memories.providerUsername': 'Nome de usuário', + 'memories.providerPassword': 'Senha', + 'memories.saveError': 'Não foi possível salvar as configurações de {provider_name}', + 'memories.selectAlbumMultiple': 'Selecionar álbum', + 'memories.selectPhotosMultiple': 'Selecionar fotos', + 'journey.title': 'Jornada', + 'journey.subtitle': 'Registre suas viagens em tempo real', + 'journey.new': 'Nova jornada', + 'journey.create': 'Criar', + 'journey.titlePlaceholder': 'Para onde você vai?', + 'journey.empty': 'Nenhuma jornada ainda', + 'journey.emptyHint': 'Comece a documentar sua próxima viagem', + 'journey.deleted': 'Jornada excluída', + 'journey.createError': 'Não foi possível criar a jornada', + 'journey.deleteError': 'Não foi possível excluir a jornada', + 'journey.deleteConfirmTitle': 'Excluir', + 'journey.deleteConfirmMessage': 'Excluir "{title}"? Isso não pode ser desfeito.', + 'journey.deleteConfirmGeneric': 'Tem certeza de que deseja excluir isso?', + 'journey.notFound': 'Jornada não encontrada', + 'journey.photos': 'Fotos', + 'journey.timelineEmpty': 'Nenhuma parada ainda', + 'journey.timelineEmptyHint': 'Adicione um check-in ou escreva uma entrada no diário para começar', + 'journey.status.draft': 'Rascunho', + 'journey.status.active': 'Ativa', + 'journey.status.completed': 'Concluída', + 'journey.status.upcoming': 'Próxima', + 'journey.checkin.add': 'Fazer check-in', + 'journey.checkin.namePlaceholder': 'Nome do local', + 'journey.checkin.notesPlaceholder': 'Notas (opcional)', + 'journey.checkin.save': 'Salvar', + 'journey.checkin.error': 'Não foi possível salvar o check-in', + 'journey.entry.add': 'Diário', + 'journey.entry.edit': 'Editar entrada', + 'journey.entry.titlePlaceholder': 'Título (opcional)', + 'journey.entry.bodyPlaceholder': 'O que aconteceu hoje?', + 'journey.entry.save': 'Salvar', + 'journey.entry.error': 'Não foi possível salvar a entrada', + 'journey.photo.add': 'Foto', + 'journey.photo.uploadError': 'Falha no envio', + 'journey.share.share': 'Compartilhar', + 'journey.share.public': 'Público', + 'journey.share.linkCopied': 'Link público copiado', + 'journey.share.disabled': 'Compartilhamento público desativado', + 'journey.editor.titlePlaceholder': 'Dê um nome a este momento...', + 'journey.editor.bodyPlaceholder': 'Conte a história deste dia...', + 'journey.editor.placePlaceholder': 'Localização (opcional)', + 'journey.editor.tagsPlaceholder': 'Tags: joia escondida, melhor refeição, preciso voltar...', + 'journey.visibility.private': 'Privado', + 'journey.visibility.shared': 'Compartilhado', + 'journey.visibility.public': 'Público', + 'journey.emptyState.title': 'Sua história começa aqui', + 'journey.emptyState.subtitle': 'Faça check-in em um lugar ou escreva sua primeira entrada no diário', + 'journey.frontpage.subtitle': 'Transforme suas viagens em histórias que você nunca vai esquecer', + 'journey.frontpage.createJourney': 'Criar jornada', + 'journey.frontpage.activeJourney': 'Jornada ativa', + 'journey.frontpage.allJourneys': 'Todas as jornadas', + 'journey.frontpage.journeys': 'jornadas', + 'journey.frontpage.createNew': 'Criar uma nova jornada', + 'journey.frontpage.createNewSub': 'Escolha viagens, escreva histórias, compartilhe suas aventuras', + 'journey.frontpage.live': 'Ao vivo', + 'journey.frontpage.synced': 'Sincronizado', + 'journey.frontpage.continueWriting': 'Continuar escrevendo', + 'journey.frontpage.updated': 'Atualizado {time}', + 'journey.frontpage.suggestionLabel': 'A viagem acabou de terminar', + 'journey.frontpage.suggestionText': 'Transforme {title} em uma jornada', + 'journey.frontpage.dismiss': 'Dispensar', + 'journey.frontpage.journeyName': 'Nome da jornada', + 'journey.frontpage.namePlaceholder': 'ex. Sudeste Asiático 2026', + 'journey.frontpage.selectTrips': 'Selecionar viagens', + 'journey.frontpage.tripsSelected': 'viagens selecionadas', + 'journey.frontpage.trips': 'viagens', + 'journey.frontpage.placesImported': 'lugares serão importados', + 'journey.frontpage.places': 'lugares', + 'journey.detail.backToJourney': 'Voltar à jornada', + 'journey.detail.syncedWithTrips': 'Sincronizado com viagens', + 'journey.detail.addEntry': 'Adicionar entrada', + 'journey.detail.newEntry': 'Nova entrada', + 'journey.detail.editEntry': 'Editar entrada', + 'journey.detail.noEntries': 'Nenhuma entrada ainda', + 'journey.detail.noEntriesHint': 'Adicione uma viagem para começar com entradas preliminares', + 'journey.detail.noPhotos': 'Nenhuma foto ainda', + 'journey.detail.noPhotosHint': 'Envie fotos para as entradas ou explore sua biblioteca do Immich/Synology', + 'journey.detail.journeyStats': 'Estatísticas da jornada', + 'journey.detail.syncedTrips': 'Viagens sincronizadas', + 'journey.detail.noTripsLinked': 'Nenhuma viagem vinculada ainda', + 'journey.detail.contributors': 'Colaboradores', + 'journey.detail.readMore': 'Ler mais', + 'journey.detail.prosCons': 'Prós e contras', + 'journey.stats.days': 'Dias', + 'journey.stats.cities': 'Cidades', + 'journey.stats.entries': 'Entradas', + 'journey.stats.photos': 'Fotos', + 'journey.stats.places': 'Lugares', + 'journey.verdict.lovedIt': 'Adorei', + 'journey.verdict.couldBeBetter': 'Poderia ser melhor', + 'journey.synced.places': 'lugares', + 'journey.synced.synced': 'sincronizado', + 'journey.editor.uploadPhotos': 'Enviar fotos', + 'journey.editor.fromGallery': 'Da galeria', + 'journey.editor.allPhotosAdded': 'Todas as fotos já foram adicionadas', + 'journey.editor.writeStory': 'Escreva sua história...', + 'journey.editor.prosCons': 'Prós e contras', + 'journey.editor.pros': 'Prós', + 'journey.editor.cons': 'Contras', + 'journey.editor.proPlaceholder': 'Algo ótimo...', + 'journey.editor.conPlaceholder': 'Não tão bom...', + 'journey.editor.addAnother': 'Adicionar outro', + 'journey.editor.date': 'Data', + 'journey.editor.location': 'Localização', + 'journey.editor.searchLocation': 'Buscar localização...', + 'journey.editor.mood': 'Humor', + 'journey.editor.weather': 'Clima', + 'journey.editor.photoFirst': '1º', + 'journey.editor.makeFirst': 'Tornar 1º', + 'journey.mood.amazing': 'Incrível', + 'journey.mood.good': 'Bom', + 'journey.mood.neutral': 'Neutro', + 'journey.mood.rough': 'Difícil', + 'journey.weather.sunny': 'Ensolarado', + 'journey.weather.partly': 'Parcialmente nublado', + 'journey.weather.cloudy': 'Nublado', + 'journey.weather.rainy': 'Chuvoso', + 'journey.weather.stormy': 'Tempestuoso', + 'journey.weather.cold': 'Nevando', + 'journey.trips.linkTrip': 'Vincular viagem', + 'journey.trips.searchTrip': 'Buscar viagem', + 'journey.trips.searchPlaceholder': 'Nome da viagem ou destino...', + 'journey.trips.noTripsAvailable': 'Nenhuma viagem disponível', + 'journey.trips.link': 'Vincular', + 'journey.trips.tripLinked': 'Viagem vinculada', + 'journey.trips.linkFailed': 'Não foi possível vincular a viagem', + 'journey.trips.addTrip': 'Adicionar viagem', + 'journey.trips.unlinkTrip': 'Desvincular viagem', + 'journey.trips.unlinkMessage': 'Desvincular "{title}"? Todas as entradas e fotos sincronizadas desta viagem serão excluídas permanentemente. Isso não pode ser desfeito.', + 'journey.trips.unlink': 'Desvincular', + 'journey.trips.tripUnlinked': 'Viagem desvinculada', + 'journey.trips.unlinkFailed': 'Não foi possível desvincular a viagem', + 'journey.trips.noTripsLinkedSettings': 'Nenhuma viagem vinculada', + 'journey.contributors.invite': 'Convidar colaborador', + 'journey.contributors.searchUser': 'Buscar usuário', + 'journey.contributors.searchPlaceholder': 'Nome de usuário ou e-mail...', + 'journey.contributors.noUsers': 'Nenhum usuário encontrado', + 'journey.contributors.role': 'Função', + 'journey.contributors.added': 'Colaborador adicionado', + 'journey.contributors.addFailed': 'Não foi possível adicionar o colaborador', + 'journey.share.publicShare': 'Compartilhamento público', + 'journey.share.createLink': 'Criar link de compartilhamento', + 'journey.share.linkCreated': 'Link de compartilhamento criado', + 'journey.share.createFailed': 'Não foi possível criar o link', + 'journey.share.copy': 'Copiar', + 'journey.share.copied': 'Copiado!', + 'journey.share.timeline': 'Linha do tempo', + 'journey.share.gallery': 'Galeria', + 'journey.share.map': 'Mapa', + 'journey.share.removeLink': 'Remover link de compartilhamento', + 'journey.share.linkDeleted': 'Link de compartilhamento removido', + 'journey.share.deleteFailed': 'Não foi possível excluir', + 'journey.share.updateFailed': 'Não foi possível atualizar', + 'journey.settings.title': 'Configurações da jornada', + 'journey.settings.coverImage': 'Imagem de capa', + 'journey.settings.changeCover': 'Alterar capa', + 'journey.settings.addCover': 'Adicionar imagem de capa', + 'journey.settings.name': 'Nome', + 'journey.settings.subtitle': 'Subtítulo', + 'journey.settings.subtitlePlaceholder': 'ex. Tailândia, Vietnã e Camboja', + 'journey.settings.delete': 'Excluir', + 'journey.settings.deleteJourney': 'Excluir jornada', + 'journey.settings.deleteMessage': 'Excluir "{title}"? Todas as entradas e fotos serão perdidas.', + 'journey.settings.saved': 'Configurações salvas', + 'journey.settings.saveFailed': 'Não foi possível salvar', + 'journey.settings.coverUpdated': 'Capa atualizada', + 'journey.settings.coverFailed': 'Falha no envio', + 'journey.public.notFound': 'Não encontrado', + 'journey.public.notFoundMessage': 'Esta jornada não existe ou o link expirou.', + 'journey.public.readOnly': 'Somente leitura · Jornada pública', + 'journey.public.tagline': 'Kit de recursos e exploração de viagens', + 'journey.public.sharedVia': 'Compartilhado via', + 'journey.public.madeWith': 'Feito com', + 'journey.pdf.journeyBook': 'Livro da jornada', + 'journey.pdf.madeWith': 'Feito com TREK', + 'journey.pdf.day': 'Dia', + 'journey.pdf.theEnd': 'Fim', + 'journey.pdf.saveAsPdf': 'Salvar como PDF', + 'journey.pdf.pages': 'páginas', + 'dashboard.greeting.morning': 'Bom dia,', + 'dashboard.greeting.afternoon': 'Boa tarde,', + 'dashboard.greeting.evening': 'Boa noite,', + 'dashboard.mobile.liveNow': 'Ao vivo agora', + 'dashboard.mobile.tripProgress': 'Progresso da viagem', + 'dashboard.mobile.daysLeft': '{count} dias restantes', + 'dashboard.mobile.places': 'Lugares', + 'dashboard.mobile.buddies': 'Companheiros', + 'dashboard.mobile.newTrip': 'Nova viagem', + 'dashboard.mobile.currency': 'Moeda', + 'dashboard.mobile.timezone': 'Fuso horário', + 'dashboard.mobile.upcomingTrips': 'Próximas viagens', + 'dashboard.mobile.yourTrips': 'Suas viagens', + 'dashboard.mobile.trips': 'viagens', + 'dashboard.mobile.starts': 'Começa', + 'dashboard.mobile.duration': 'Duração', + 'dashboard.mobile.day': 'dia', + 'dashboard.mobile.days': 'dias', + 'dashboard.mobile.ongoing': 'Em andamento', + 'dashboard.mobile.startsToday': 'Começa hoje', + 'dashboard.mobile.tomorrow': 'Amanhã', + 'dashboard.mobile.inDays': 'Em {count} dias', + 'dashboard.mobile.inMonths': 'Em {count} meses', + 'dashboard.mobile.completed': 'Concluído', + 'dashboard.mobile.currencyConverter': 'Conversor de moedas', + 'nav.profile': 'Perfil', + 'nav.bottomSettings': 'Configurações', + 'nav.bottomAdmin': 'Administração', + 'nav.bottomLogout': 'Sair', + 'nav.bottomAdminBadge': 'Admin', + 'dayplan.mobile.addPlace': 'Adicionar lugar', + 'dayplan.mobile.searchPlaces': 'Buscar lugares...', + 'dayplan.mobile.allAssigned': 'Todos os lugares atribuídos', + 'dayplan.mobile.noMatch': 'Sem correspondência', + 'dayplan.mobile.createNew': 'Criar novo lugar', + 'admin.addons.catalog.journey.name': 'Jornada', + 'admin.addons.catalog.journey.description': 'Rastreamento de viagens e diário de viajante com check-ins, fotos e histórias diárias', } export default br diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 130f9623..24bef1ac 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -1690,6 +1690,239 @@ const cs: Record = { 'notif.generic.text': 'Máte nové oznámení', 'notif.dev.unknown_event.title': '[DEV] Neznámá událost', 'notif.dev.unknown_event.text': 'Typ události "{event}" není registrován v EVENT_NOTIFICATION_CONFIG', + + // Journey, Dashboard, Nav, DayPlan + 'common.justNow': 'právě teď', + 'common.hoursAgo': 'před {count} h', + 'common.daysAgo': 'před {count} d', + 'budget.linkedToReservation': 'Propojeno s rezervací — upravte název tam', + 'packing.saveAsTemplate': 'Uložit jako šablonu', + 'packing.templateName': 'Název šablony', + 'packing.templateSaved': 'Balicí seznam uložen jako šablona', + 'memories.notConnectedMultipleHint': 'Připojte některého z těchto poskytovatelů fotek: {provider_names} v Nastavení, abyste mohli přidávat fotky k tomuto výletu.', + 'memories.providerUrl': 'URL serveru', + 'memories.providerApiKey': 'API klíč', + 'memories.providerUsername': 'Uživatelské jméno', + 'memories.providerPassword': 'Heslo', + 'memories.saveError': 'Nepodařilo se uložit nastavení {provider_name}', + 'memories.selectAlbumMultiple': 'Vybrat album', + 'memories.selectPhotosMultiple': 'Vybrat fotky', + 'journey.title': 'Cestovní deník', + 'journey.subtitle': 'Zaznamenávejte své cesty průběžně', + 'journey.new': 'Nový cestovní deník', + 'journey.create': 'Vytvořit', + 'journey.titlePlaceholder': 'Kam jedete?', + 'journey.empty': 'Zatím žádné cestovní deníky', + 'journey.emptyHint': 'Začněte dokumentovat svůj další výlet', + 'journey.deleted': 'Cestovní deník smazán', + 'journey.createError': 'Nepodařilo se vytvořit cestovní deník', + 'journey.deleteError': 'Nepodařilo se smazat cestovní deník', + 'journey.deleteConfirmTitle': 'Smazat', + 'journey.deleteConfirmMessage': 'Smazat „{title}"? Tuto akci nelze vrátit zpět.', + 'journey.deleteConfirmGeneric': 'Opravdu to chcete smazat?', + 'journey.notFound': 'Cestovní deník nenalezen', + 'journey.photos': 'Fotky', + 'journey.timelineEmpty': 'Zatím žádné zastávky', + 'journey.timelineEmptyHint': 'Přidejte odbavení nebo napište záznam do deníku', + 'journey.status.draft': 'Koncept', + 'journey.status.active': 'Aktivní', + 'journey.status.completed': 'Dokončeno', + 'journey.status.upcoming': 'Nadcházející', + 'journey.checkin.add': 'Odbavit se', + 'journey.checkin.namePlaceholder': 'Název místa', + 'journey.checkin.notesPlaceholder': 'Poznámky (volitelné)', + 'journey.checkin.save': 'Uložit', + 'journey.checkin.error': 'Nepodařilo se uložit odbavení', + 'journey.entry.add': 'Deník', + 'journey.entry.edit': 'Upravit záznam', + 'journey.entry.titlePlaceholder': 'Název (volitelný)', + 'journey.entry.bodyPlaceholder': 'Co se dnes stalo?', + 'journey.entry.save': 'Uložit', + 'journey.entry.error': 'Nepodařilo se uložit záznam', + 'journey.photo.add': 'Fotka', + 'journey.photo.uploadError': 'Nahrávání selhalo', + 'journey.share.share': 'Sdílet', + 'journey.share.public': 'Veřejný', + 'journey.share.linkCopied': 'Veřejný odkaz zkopírován', + 'journey.share.disabled': 'Veřejné sdílení vypnuto', + 'journey.editor.titlePlaceholder': 'Pojmenujte tento okamžik...', + 'journey.editor.bodyPlaceholder': 'Vyprávějte příběh tohoto dne...', + 'journey.editor.placePlaceholder': 'Místo (volitelné)', + 'journey.editor.tagsPlaceholder': 'Tagy: skrytý klenot, nejlepší jídlo, musím se vrátit...', + 'journey.visibility.private': 'Soukromý', + 'journey.visibility.shared': 'Sdílený', + 'journey.visibility.public': 'Veřejný', + 'journey.emptyState.title': 'Váš příběh začíná zde', + 'journey.emptyState.subtitle': 'Odba‍vte se na místě nebo napište svůj první záznam do deníku', + 'journey.frontpage.subtitle': 'Proměňte své cesty v příběhy, na které nikdy nezapomenete', + 'journey.frontpage.createJourney': 'Vytvořit cestovní deník', + 'journey.frontpage.activeJourney': 'Aktivní cestovní deník', + 'journey.frontpage.allJourneys': 'Všechny cestovní deníky', + 'journey.frontpage.journeys': 'cestovní deníky', + 'journey.frontpage.createNew': 'Vytvořit nový cestovní deník', + 'journey.frontpage.createNewSub': 'Vyberte cesty, pište příběhy, sdílejte dobrodružství', + 'journey.frontpage.live': 'Živě', + 'journey.frontpage.synced': 'Synchronizováno', + 'journey.frontpage.continueWriting': 'Pokračovat v psaní', + 'journey.frontpage.updated': 'Aktualizováno {time}', + 'journey.frontpage.suggestionLabel': 'Cesta právě skončila', + 'journey.frontpage.suggestionText': 'Proměňte {title} v cestovní deník', + 'journey.frontpage.dismiss': 'Zavřít', + 'journey.frontpage.journeyName': 'Název cestovního deníku', + 'journey.frontpage.namePlaceholder': 'např. Jihovýchodní Asie 2026', + 'journey.frontpage.selectTrips': 'Vybrat cesty', + 'journey.frontpage.tripsSelected': 'cest vybráno', + 'journey.frontpage.trips': 'cesty', + 'journey.frontpage.placesImported': 'míst bude importováno', + 'journey.frontpage.places': 'místa', + 'journey.detail.backToJourney': 'Zpět na cestovní deník', + 'journey.detail.syncedWithTrips': 'Synchronizováno s cestami', + 'journey.detail.addEntry': 'Přidat záznam', + 'journey.detail.newEntry': 'Nový záznam', + 'journey.detail.editEntry': 'Upravit záznam', + 'journey.detail.noEntries': 'Zatím žádné záznamy', + 'journey.detail.noEntriesHint': 'Přidejte cestu pro začátek s kostrovými záznamy', + 'journey.detail.noPhotos': 'Zatím žádné fotky', + 'journey.detail.noPhotosHint': 'Nahrajte fotky k záznamům nebo procházejte knihovnu Immich/Synology', + 'journey.detail.journeyStats': 'Statistiky cesty', + 'journey.detail.syncedTrips': 'Synchronizované cesty', + 'journey.detail.noTripsLinked': 'Zatím žádné propojené cesty', + 'journey.detail.contributors': 'Přispěvatelé', + 'journey.detail.readMore': 'Číst dále', + 'journey.detail.prosCons': 'Klady a zápory', + 'journey.stats.days': 'Dny', + 'journey.stats.cities': 'Města', + 'journey.stats.entries': 'Záznamy', + 'journey.stats.photos': 'Fotky', + 'journey.stats.places': 'Místa', + 'journey.verdict.lovedIt': 'Skvělé', + 'journey.verdict.couldBeBetter': 'Mohlo by být lepší', + 'journey.synced.places': 'místa', + 'journey.synced.synced': 'synchronizováno', + 'journey.editor.uploadPhotos': 'Nahrát fotky', + 'journey.editor.fromGallery': 'Z galerie', + 'journey.editor.allPhotosAdded': 'Všechny fotky již přidány', + 'journey.editor.writeStory': 'Napište svůj příběh...', + 'journey.editor.prosCons': 'Klady a zápory', + 'journey.editor.pros': 'Klady', + 'journey.editor.cons': 'Zápory', + 'journey.editor.proPlaceholder': 'Něco skvělého...', + 'journey.editor.conPlaceholder': 'Ne tak skvělé...', + 'journey.editor.addAnother': 'Přidat další', + 'journey.editor.date': 'Datum', + 'journey.editor.location': 'Místo', + 'journey.editor.searchLocation': 'Hledat místo...', + 'journey.editor.mood': 'Nálada', + 'journey.editor.weather': 'Počasí', + 'journey.editor.photoFirst': '1.', + 'journey.editor.makeFirst': 'Nastavit jako 1.', + 'journey.mood.amazing': 'Úžasný', + 'journey.mood.good': 'Dobrý', + 'journey.mood.neutral': 'Neutrální', + 'journey.mood.rough': 'Těžký', + 'journey.weather.sunny': 'Slunečno', + 'journey.weather.partly': 'Polojasno', + 'journey.weather.cloudy': 'Zataženo', + 'journey.weather.rainy': 'Deštivo', + 'journey.weather.stormy': 'Bouřlivo', + 'journey.weather.cold': 'Sněžení', + 'journey.trips.linkTrip': 'Propojit cestu', + 'journey.trips.searchTrip': 'Hledat cestu', + 'journey.trips.searchPlaceholder': 'Název cesty nebo cíl...', + 'journey.trips.noTripsAvailable': 'Žádné dostupné cesty', + 'journey.trips.link': 'Propojit', + 'journey.trips.tripLinked': 'Cesta propojena', + 'journey.trips.linkFailed': 'Propojení cesty selhalo', + 'journey.trips.addTrip': 'Přidat cestu', + 'journey.trips.unlinkTrip': 'Odpojit cestu', + 'journey.trips.unlinkMessage': 'Odpojit „{title}"? Všechny synchronizované záznamy a fotky z této cesty budou trvale smazány. Tuto akci nelze vrátit zpět.', + 'journey.trips.unlink': 'Odpojit', + 'journey.trips.tripUnlinked': 'Cesta odpojena', + 'journey.trips.unlinkFailed': 'Odpojení cesty selhalo', + 'journey.trips.noTripsLinkedSettings': 'Žádné propojené cesty', + 'journey.contributors.invite': 'Pozvat přispěvatele', + 'journey.contributors.searchUser': 'Hledat uživatele', + 'journey.contributors.searchPlaceholder': 'Uživatelské jméno nebo e-mail...', + 'journey.contributors.noUsers': 'Žádní uživatelé nenalezeni', + 'journey.contributors.role': 'Role', + 'journey.contributors.added': 'Přispěvatel přidán', + 'journey.contributors.addFailed': 'Přidání přispěvatele selhalo', + 'journey.share.publicShare': 'Veřejné sdílení', + 'journey.share.createLink': 'Vytvořit odkaz ke sdílení', + 'journey.share.linkCreated': 'Odkaz ke sdílení vytvořen', + 'journey.share.createFailed': 'Vytvoření odkazu selhalo', + 'journey.share.copy': 'Kopírovat', + 'journey.share.copied': 'Zkopírováno!', + 'journey.share.timeline': 'Časová osa', + 'journey.share.gallery': 'Galerie', + 'journey.share.map': 'Mapa', + 'journey.share.removeLink': 'Odstranit odkaz ke sdílení', + 'journey.share.linkDeleted': 'Odkaz ke sdílení smazán', + 'journey.share.deleteFailed': 'Smazání selhalo', + 'journey.share.updateFailed': 'Aktualizace selhala', + 'journey.settings.title': 'Nastavení cestovního deníku', + 'journey.settings.coverImage': 'Titulní obrázek', + 'journey.settings.changeCover': 'Změnit obal', + 'journey.settings.addCover': 'Přidat titulní obrázek', + 'journey.settings.name': 'Název', + 'journey.settings.subtitle': 'Podtitul', + 'journey.settings.subtitlePlaceholder': 'např. Thajsko, Vietnam a Kambodža', + 'journey.settings.delete': 'Smazat', + 'journey.settings.deleteJourney': 'Smazat cestovní deník', + 'journey.settings.deleteMessage': 'Smazat „{title}"? Všechny záznamy a fotky budou ztraceny.', + 'journey.settings.saved': 'Nastavení uloženo', + 'journey.settings.saveFailed': 'Uložení selhalo', + 'journey.settings.coverUpdated': 'Obal aktualizován', + 'journey.settings.coverFailed': 'Nahrávání selhalo', + 'journey.public.notFound': 'Nenalezeno', + 'journey.public.notFoundMessage': 'Tento cestovní deník neexistuje nebo odkaz vypršel.', + 'journey.public.readOnly': 'Pouze ke čtení · Veřejný cestovní deník', + 'journey.public.tagline': 'Travel Resource & Exploration Kit', + 'journey.public.sharedVia': 'Sdíleno přes', + 'journey.public.madeWith': 'Vytvořeno pomocí', + 'journey.pdf.journeyBook': 'Cestovní kniha', + 'journey.pdf.madeWith': 'Vytvořeno pomocí TREK', + 'journey.pdf.day': 'Den', + 'journey.pdf.theEnd': 'Konec', + 'journey.pdf.saveAsPdf': 'Uložit jako PDF', + 'journey.pdf.pages': 'stran', + 'dashboard.greeting.morning': 'Dobré ráno,', + 'dashboard.greeting.afternoon': 'Dobré odpoledne,', + 'dashboard.greeting.evening': 'Dobrý večer,', + 'dashboard.mobile.liveNow': 'Živě', + 'dashboard.mobile.tripProgress': 'Průběh cesty', + 'dashboard.mobile.daysLeft': 'Zbývá {count} dní', + 'dashboard.mobile.places': 'Místa', + 'dashboard.mobile.buddies': 'Spolucestující', + 'dashboard.mobile.newTrip': 'Nová cesta', + 'dashboard.mobile.currency': 'Měna', + 'dashboard.mobile.timezone': 'Časové pásmo', + 'dashboard.mobile.upcomingTrips': 'Nadcházející cesty', + 'dashboard.mobile.yourTrips': 'Vaše cesty', + 'dashboard.mobile.trips': 'cesty', + 'dashboard.mobile.starts': 'Začátek', + 'dashboard.mobile.duration': 'Doba trvání', + 'dashboard.mobile.day': 'den', + 'dashboard.mobile.days': 'dní', + 'dashboard.mobile.ongoing': 'Probíhající', + 'dashboard.mobile.startsToday': 'Začíná dnes', + 'dashboard.mobile.tomorrow': 'Zítra', + 'dashboard.mobile.inDays': 'Za {count} dní', + 'dashboard.mobile.inMonths': 'Za {count} měsíců', + 'dashboard.mobile.completed': 'Dokončeno', + 'dashboard.mobile.currencyConverter': 'Převodník měn', + 'nav.profile': 'Profil', + 'nav.bottomSettings': 'Nastavení', + 'nav.bottomAdmin': 'Nastavení správce', + 'nav.bottomLogout': 'Odhlásit se', + 'nav.bottomAdminBadge': 'Správce', + 'dayplan.mobile.addPlace': 'Přidat místo', + 'dayplan.mobile.searchPlaces': 'Hledat místa...', + 'dayplan.mobile.allAssigned': 'Všechna místa přiřazena', + 'dayplan.mobile.noMatch': 'Žádná shoda', + 'dayplan.mobile.createNew': 'Vytvořit nové místo', + 'admin.addons.catalog.journey.name': 'Cestovní deník', + 'admin.addons.catalog.journey.description': 'Sledování cest a cestovní deník s odbaveními, fotkami a denními příběhy', } export default cs diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index c9ebf453..e50759e8 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -26,6 +26,9 @@ const de: Record = { 'common.email': 'E-Mail', 'common.password': 'Passwort', 'common.saving': 'Speichern...', + 'common.justNow': 'gerade eben', + 'common.hoursAgo': 'vor {count}h', + 'common.daysAgo': 'vor {count}T', 'common.saved': 'Gespeichert', 'trips.reminder': 'Erinnerung', 'trips.reminderNone': 'Keine', @@ -179,9 +182,6 @@ const de: Record = { 'admin.notifications.none': 'Deaktiviert', 'admin.notifications.email': 'E-Mail (SMTP)', 'admin.notifications.webhook': 'Webhook', - 'admin.notifications.events': 'Benachrichtigungsereignisse', - 'admin.notifications.eventsHint': 'Wähle, welche Ereignisse Benachrichtigungen für alle Benutzer auslösen.', - 'admin.notifications.configureFirst': 'Konfiguriere zuerst die SMTP- oder Webhook-Einstellungen unten, dann aktiviere die Events.', 'admin.notifications.save': 'Benachrichtigungseinstellungen speichern', 'admin.notifications.saved': 'Benachrichtigungseinstellungen gespeichert', 'admin.notifications.testWebhook': 'Test-Webhook senden', @@ -1110,7 +1110,6 @@ const de: Record = { 'packing.saveAsTemplate': 'Als Vorlage speichern', 'packing.templateName': 'Vorlagenname', 'packing.templateSaved': 'Packliste als Vorlage gespeichert', - 'packing.assignUser': 'Person zuweisen', 'packing.bags': 'Gepäck', 'packing.noBag': 'Nicht zugeordnet', 'packing.totalWeight': 'Gesamtgewicht', @@ -1394,8 +1393,6 @@ const de: Record = { 'memories.reviewTitle': 'Deine Fotos prüfen', 'memories.reviewHint': 'Klicke auf Fotos, um sie vom Teilen auszuschließen.', 'memories.shareCount': '{count} Fotos teilen', - 'memories.immichUrl': 'Immich Server URL', - 'memories.immichApiKey': 'API-Schlüssel', 'memories.testConnection': 'Verbindung testen', 'memories.testFirst': 'Verbindung zuerst testen', 'memories.connected': 'Verbunden', @@ -1692,6 +1689,234 @@ const de: Record = { 'notif.generic.text': 'Du hast eine neue Benachrichtigung', 'notif.dev.unknown_event.title': '[DEV] Unbekanntes Ereignis', 'notif.dev.unknown_event.text': 'Ereignistyp "{event}" ist nicht in EVENT_NOTIFICATION_CONFIG registriert', + + // Journey Addon + 'journey.title': 'Journey', + 'journey.subtitle': 'Dokumentiere deine Reisen unterwegs', + 'journey.new': 'Neue Journey', + 'journey.create': 'Erstellen', + 'journey.titlePlaceholder': 'Wohin geht die Reise?', + 'journey.empty': 'Noch keine Journeys', + 'journey.emptyHint': 'Starte die Dokumentation deiner naechsten Reise', + 'journey.deleted': 'Journey geloescht', + 'journey.createError': 'Journey konnte nicht erstellt werden', + 'journey.deleteError': 'Journey konnte nicht geloescht werden', + 'journey.deleteConfirmTitle': 'Loeschen', + 'journey.deleteConfirmMessage': '"{title}" loeschen? Das kann nicht rueckgaengig gemacht werden.', + 'journey.deleteConfirmGeneric': 'Bist du sicher, dass du das loeschen moechtest?', + 'journey.notFound': 'Journey nicht gefunden', + 'journey.photos': 'Fotos', + 'journey.timelineEmpty': 'Noch keine Stationen', + 'journey.timelineEmptyHint': 'Fuege einen Check-in hinzu oder schreibe einen Tagebucheintrag', + 'journey.status.draft': 'Entwurf', + 'journey.status.active': 'Aktiv', + 'journey.status.completed': 'Abgeschlossen', + 'journey.status.upcoming': 'Anstehend', + 'journey.checkin.add': 'Einchecken', + 'journey.checkin.namePlaceholder': 'Ortsname', + 'journey.checkin.notesPlaceholder': 'Notizen (optional)', + 'journey.checkin.save': 'Speichern', + 'journey.checkin.error': 'Check-in konnte nicht gespeichert werden', + 'journey.entry.add': 'Tagebuch', + 'journey.entry.edit': 'Eintrag bearbeiten', + 'journey.entry.titlePlaceholder': 'Titel (optional)', + 'journey.entry.bodyPlaceholder': 'Was ist heute passiert?', + 'journey.entry.save': 'Speichern', + 'journey.entry.error': 'Eintrag konnte nicht gespeichert werden', + 'journey.photo.add': 'Foto', + 'journey.photo.uploadError': 'Upload fehlgeschlagen', + 'journey.share.share': 'Teilen', + 'journey.share.public': 'Oeffentlich', + 'journey.share.linkCopied': 'Oeffentlicher Link kopiert', + 'journey.share.disabled': 'Oeffentliches Teilen deaktiviert', + 'journey.editor.titlePlaceholder': 'Gib diesem Moment einen Namen...', + 'journey.editor.bodyPlaceholder': 'Erzaehl die Geschichte dieses Tages...', + 'journey.editor.placePlaceholder': 'Ort (optional)', + 'journey.editor.tagsPlaceholder': 'Tags: Geheimtipp, bestes Essen, nochmal hin...', + 'journey.visibility.private': 'Privat', + 'journey.visibility.shared': 'Geteilt', + 'journey.visibility.public': 'Oeffentlich', + 'journey.emptyState.title': 'Deine Geschichte beginnt hier', + 'journey.emptyState.subtitle': 'Checke an einem Ort ein oder schreibe deinen ersten Tagebucheintrag', + 'admin.addons.catalog.journey.name': 'Journey', + 'admin.addons.catalog.journey.description': 'Reise-Tracking & Tagebuch mit Check-ins, Fotos und Tagesberichten', + + // Journey & Mobile translations + 'journey.frontpage.subtitle': 'Verwandle deine Reisen in Geschichten, die du nie vergisst', + 'journey.frontpage.createJourney': 'Journey erstellen', + 'journey.frontpage.activeJourney': 'Aktive Journey', + 'journey.frontpage.allJourneys': 'Alle Journeys', + 'journey.frontpage.journeys': 'Journeys', + 'journey.frontpage.createNew': 'Neue Journey erstellen', + 'journey.frontpage.createNewSub': 'Trips auswählen, Geschichten schreiben, Abenteuer teilen', + 'journey.frontpage.live': 'Live', + 'journey.frontpage.synced': 'Synchronisiert', + 'journey.frontpage.continueWriting': 'Weiterschreiben', + 'journey.frontpage.updated': 'Aktualisiert {time}', + 'journey.frontpage.suggestionLabel': 'Trip gerade beendet', + 'journey.frontpage.suggestionText': 'Verwandle {title} in eine Journey', + 'journey.frontpage.dismiss': 'Schließen', + 'journey.frontpage.journeyName': 'Journey-Name', + 'journey.frontpage.namePlaceholder': 'z.B. Südostasien 2026', + 'journey.frontpage.selectTrips': 'Trips auswählen', + 'journey.frontpage.tripsSelected': 'Trips ausgewählt', + 'journey.frontpage.trips': 'Trips', + 'journey.frontpage.placesImported': 'Orte werden importiert', + 'journey.frontpage.places': 'Orte', + 'journey.detail.backToJourney': 'Zurück zur Journey', + 'journey.detail.syncedWithTrips': 'Mit Trips synchronisiert', + 'journey.detail.addEntry': 'Eintrag hinzufügen', + 'journey.detail.newEntry': 'Neuer Eintrag', + 'journey.detail.editEntry': 'Eintrag bearbeiten', + 'journey.detail.noEntries': 'Noch keine Einträge', + 'journey.detail.noEntriesHint': 'Füge einen Trip hinzu, um mit Skelett-Einträgen zu starten', + 'journey.detail.noPhotos': 'Noch keine Fotos', + 'journey.detail.noPhotosHint': 'Lade Fotos hoch oder durchsuche deine Immich/Synology-Bibliothek', + 'journey.detail.journeyStats': 'Journey-Statistiken', + 'journey.detail.syncedTrips': 'Verknüpfte Trips', + 'journey.detail.noTripsLinked': 'Noch keine Trips verknüpft', + 'journey.detail.contributors': 'Mitwirkende', + 'journey.detail.readMore': 'Mehr lesen', + 'journey.detail.prosCons': 'Pro & Contra', + 'journey.stats.days': 'Tage', + 'journey.stats.cities': 'Städte', + 'journey.stats.entries': 'Einträge', + 'journey.stats.photos': 'Fotos', + 'journey.stats.places': 'Orte', + 'journey.verdict.lovedIt': 'Toll', + 'journey.verdict.couldBeBetter': 'Verbesserungswürdig', + 'journey.synced.places': 'Orte', + 'journey.synced.synced': 'synchronisiert', + 'journey.editor.uploadPhotos': 'Fotos hochladen', + 'journey.editor.fromGallery': 'Aus Galerie', + 'journey.editor.allPhotosAdded': 'Alle Fotos bereits hinzugefügt', + 'journey.editor.writeStory': 'Erzähle deine Geschichte...', + 'journey.editor.prosCons': 'Pro & Contra', + 'journey.editor.pros': 'Pro', + 'journey.editor.cons': 'Contra', + 'journey.editor.proPlaceholder': 'Etwas Positives...', + 'journey.editor.conPlaceholder': 'Nicht so toll...', + 'journey.editor.addAnother': 'Hinzufügen', + 'journey.editor.date': 'Datum', + 'journey.editor.location': 'Ort', + 'journey.editor.searchLocation': 'Ort suchen...', + 'journey.editor.mood': 'Stimmung', + 'journey.editor.weather': 'Wetter', + 'journey.editor.photoFirst': '1.', + 'journey.editor.makeFirst': 'Als 1. setzen', + 'journey.mood.amazing': 'Großartig', + 'journey.mood.good': 'Gut', + 'journey.mood.neutral': 'Neutral', + 'journey.mood.rough': 'Schwierig', + 'journey.weather.sunny': 'Sonnig', + 'journey.weather.partly': 'Teilweise bewölkt', + 'journey.weather.cloudy': 'Bewölkt', + 'journey.weather.rainy': 'Regnerisch', + 'journey.weather.stormy': 'Stürmisch', + 'journey.weather.cold': 'Schnee', + 'journey.trips.linkTrip': 'Trip verknüpfen', + 'journey.trips.searchTrip': 'Trip suchen', + 'journey.trips.searchPlaceholder': 'Tripname oder Reiseziel...', + 'journey.trips.noTripsAvailable': 'Keine Trips verfügbar', + 'journey.trips.link': 'Verknüpfen', + 'journey.trips.tripLinked': 'Trip verknüpft', + 'journey.trips.linkFailed': 'Verknüpfung fehlgeschlagen', + 'journey.trips.addTrip': 'Trip hinzufügen', + 'journey.trips.unlinkTrip': 'Trip trennen', + 'journey.trips.unlinkMessage': '"{title}" trennen? Alle synchronisierten Einträge und Fotos dieses Trips werden unwiderruflich gelöscht.', + 'journey.trips.unlink': 'Trennen', + 'journey.trips.tripUnlinked': 'Trip getrennt', + 'journey.trips.unlinkFailed': 'Trennung fehlgeschlagen', + 'journey.trips.noTripsLinkedSettings': 'Keine Trips verknüpft', + 'journey.contributors.invite': 'Mitwirkenden einladen', + 'journey.contributors.searchUser': 'Benutzer suchen', + 'journey.contributors.searchPlaceholder': 'Benutzername oder E-Mail...', + 'journey.contributors.noUsers': 'Keine Benutzer gefunden', + 'journey.contributors.role': 'Rolle', + 'journey.contributors.added': 'Mitwirkender hinzugefügt', + 'journey.contributors.addFailed': 'Hinzufügen fehlgeschlagen', + 'journey.share.publicShare': 'Öffentlicher Link', + 'journey.share.createLink': 'Link erstellen', + 'journey.share.linkCreated': 'Link erstellt', + 'journey.share.createFailed': 'Link konnte nicht erstellt werden', + 'journey.share.copy': 'Kopieren', + 'journey.share.copied': 'Kopiert!', + 'journey.share.timeline': 'Zeitstrahl', + 'journey.share.gallery': 'Galerie', + 'journey.share.map': 'Karte', + 'journey.share.removeLink': 'Link entfernen', + 'journey.share.linkDeleted': 'Link entfernt', + 'journey.share.deleteFailed': 'Entfernen fehlgeschlagen', + 'journey.share.updateFailed': 'Aktualisierung fehlgeschlagen', + 'journey.settings.title': 'Journey-Einstellungen', + 'journey.settings.coverImage': 'Titelbild', + 'journey.settings.changeCover': 'Titelbild ändern', + 'journey.settings.addCover': 'Titelbild hinzufügen', + 'journey.settings.name': 'Name', + 'journey.settings.subtitle': 'Untertitel', + 'journey.settings.subtitlePlaceholder': 'z.B. Thailand, Vietnam & Kambodscha', + 'journey.settings.delete': 'Löschen', + 'journey.settings.deleteJourney': 'Journey löschen', + 'journey.settings.deleteMessage': '"{title}" löschen? Alle Einträge und Fotos gehen verloren.', + 'journey.settings.saved': 'Einstellungen gespeichert', + 'journey.settings.saveFailed': 'Speichern fehlgeschlagen', + 'journey.settings.coverUpdated': 'Titelbild aktualisiert', + 'journey.settings.coverFailed': 'Upload fehlgeschlagen', + 'journey.public.notFound': 'Nicht gefunden', + 'journey.public.notFoundMessage': 'Diese Journey existiert nicht oder der Link ist abgelaufen.', + 'journey.public.readOnly': 'Nur lesen · Öffentliche Journey', + 'journey.public.tagline': 'Travel Resource & Exploration Kit', + 'journey.public.sharedVia': 'Geteilt über', + 'journey.public.madeWith': 'Erstellt mit', + 'journey.pdf.journeyBook': 'Reisebuch', + 'journey.pdf.madeWith': 'Erstellt mit TREK', + 'journey.pdf.day': 'Tag', + 'journey.pdf.theEnd': 'Ende', + 'journey.pdf.saveAsPdf': 'Als PDF speichern', + 'journey.pdf.pages': 'Seiten', + 'dashboard.greeting.morning': 'Guten Morgen,', + 'dashboard.greeting.afternoon': 'Guten Tag,', + 'dashboard.greeting.evening': 'Guten Abend,', + 'dashboard.mobile.liveNow': 'Jetzt live', + 'dashboard.mobile.tripProgress': 'Reisefortschritt', + 'dashboard.mobile.daysLeft': '{count} Tage übrig', + 'dashboard.mobile.places': 'Orte', + 'dashboard.mobile.buddies': 'Freunde', + 'dashboard.mobile.newTrip': 'Neuer Trip', + 'dashboard.mobile.currency': 'Währung', + 'dashboard.mobile.timezone': 'Zeitzone', + 'dashboard.mobile.upcomingTrips': 'Anstehende Trips', + 'dashboard.mobile.yourTrips': 'Deine Trips', + 'dashboard.mobile.trips': 'Trips', + 'dashboard.mobile.starts': 'Beginn', + 'dashboard.mobile.duration': 'Dauer', + 'dashboard.mobile.day': 'Tag', + 'dashboard.mobile.days': 'Tage', + 'dashboard.mobile.ongoing': 'Laufend', + 'dashboard.mobile.startsToday': 'Beginnt heute', + 'dashboard.mobile.tomorrow': 'Morgen', + 'dashboard.mobile.inDays': 'In {count} Tagen', + 'dashboard.mobile.inMonths': 'In {count} Monaten', + 'dashboard.mobile.completed': 'Abgeschlossen', + 'dashboard.mobile.currencyConverter': 'Währungsrechner', + 'nav.profile': 'Profil', + 'nav.bottomSettings': 'Einstellungen', + 'nav.bottomAdmin': 'Admin-Einstellungen', + 'nav.bottomLogout': 'Abmelden', + 'nav.bottomAdminBadge': 'Admin', + 'dayplan.mobile.addPlace': 'Ort hinzufügen', + 'dayplan.mobile.searchPlaces': 'Orte suchen...', + 'dayplan.mobile.allAssigned': 'Alle Orte zugeordnet', + 'dayplan.mobile.noMatch': 'Kein Treffer', + 'dayplan.mobile.createNew': 'Neuen Ort erstellen', + 'memories.notConnectedMultipleHint': 'Connect any of these photo providers: {provider_names} in Settings to be able add photos to this trip.', + 'memories.providerUrl': 'Server URL', + 'memories.providerApiKey': 'API Key', + 'memories.providerUsername': 'Username', + 'memories.providerPassword': 'Password', + 'memories.saveError': 'Could not save {provider_name} settings', + 'memories.selectAlbumMultiple': 'Select Album', + 'memories.selectPhotosMultiple': 'Select Photos', } export default de diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 2cb895a1..a811e318 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -26,6 +26,9 @@ const en: Record = { 'common.email': 'Email', 'common.password': 'Password', 'common.saving': 'Saving...', + 'common.justNow': 'just now', + 'common.hoursAgo': '{count}h ago', + 'common.daysAgo': '{count}d ago', 'common.saved': 'Saved', 'trips.reminder': 'Reminder', 'trips.reminderNone': 'None', @@ -1698,6 +1701,259 @@ const en: Record = { 'notif.generic.text': 'You have a new notification', 'notif.dev.unknown_event.title': '[DEV] Unknown Event', 'notif.dev.unknown_event.text': 'Event type "{event}" is not registered in EVENT_NOTIFICATION_CONFIG', + + // Journey addon + 'journey.title': 'Journey', + 'journey.subtitle': 'Track your travels as they happen', + 'journey.new': 'New Journey', + 'journey.create': 'Create', + 'journey.titlePlaceholder': 'Where are you going?', + 'journey.empty': 'No journeys yet', + 'journey.emptyHint': 'Start documenting your next trip', + 'journey.deleted': 'Journey deleted', + 'journey.createError': 'Could not create journey', + 'journey.deleteError': 'Could not delete journey', + 'journey.deleteConfirmTitle': 'Delete', + 'journey.deleteConfirmMessage': 'Delete "{title}"? This cannot be undone.', + 'journey.deleteConfirmGeneric': 'Are you sure you want to delete this?', + 'journey.notFound': 'Journey not found', + 'journey.photos': 'Photos', + 'journey.timelineEmpty': 'No stops yet', + 'journey.timelineEmptyHint': 'Add a check-in or write a journal entry to get started', + 'journey.status.draft': 'Draft', + 'journey.status.active': 'Active', + 'journey.status.completed': 'Completed', + 'journey.status.upcoming': 'Upcoming', + 'journey.checkin.add': 'Check in', + 'journey.checkin.namePlaceholder': 'Location name', + 'journey.checkin.notesPlaceholder': 'Notes (optional)', + 'journey.checkin.save': 'Save', + 'journey.checkin.error': 'Could not save check-in', + 'journey.entry.add': 'Journal', + 'journey.entry.edit': 'Edit entry', + 'journey.entry.titlePlaceholder': 'Title (optional)', + 'journey.entry.bodyPlaceholder': 'What happened today?', + 'journey.entry.save': 'Save', + 'journey.entry.error': 'Could not save entry', + 'journey.photo.add': 'Photo', + 'journey.photo.uploadError': 'Upload failed', + 'journey.share.share': 'Share', + 'journey.share.public': 'Public', + 'journey.share.linkCopied': 'Public link copied', + 'journey.share.disabled': 'Public sharing disabled', + 'journey.editor.titlePlaceholder': 'Give this moment a name...', + 'journey.editor.bodyPlaceholder': 'Tell the story of this day...', + 'journey.editor.placePlaceholder': 'Location (optional)', + 'journey.editor.tagsPlaceholder': 'Tags: hidden gem, best meal, must revisit...', + 'journey.visibility.private': 'Private', + 'journey.visibility.shared': 'Shared', + 'journey.visibility.public': 'Public', + 'journey.emptyState.title': 'Your story starts here', + 'journey.emptyState.subtitle': 'Check in at a place or write your first journal entry', + + // Journey Frontpage + 'journey.frontpage.subtitle': 'Turn your trips into stories you\'ll never forget', + 'journey.frontpage.createJourney': 'Create Journey', + 'journey.frontpage.activeJourney': 'Active Journey', + 'journey.frontpage.allJourneys': 'All Journeys', + 'journey.frontpage.journeys': 'journeys', + 'journey.frontpage.createNew': 'Create a new Journey', + 'journey.frontpage.createNewSub': 'Pick trips, write stories, share your adventures', + 'journey.frontpage.live': 'Live', + 'journey.frontpage.synced': 'Synced', + 'journey.frontpage.continueWriting': 'Continue writing', + 'journey.frontpage.updated': 'Updated {time}', + 'journey.frontpage.suggestionLabel': 'Trip just ended', + 'journey.frontpage.suggestionText': 'Turn {title} into a Journey', + 'journey.frontpage.dismiss': 'Dismiss', + 'journey.frontpage.journeyName': 'Journey Name', + 'journey.frontpage.namePlaceholder': 'e.g. Southeast Asia 2026', + 'journey.frontpage.selectTrips': 'Select Trips', + 'journey.frontpage.tripsSelected': 'trips selected', + 'journey.frontpage.trips': 'trips', + 'journey.frontpage.placesImported': 'places will be imported', + 'journey.frontpage.places': 'places', + + // Journey Detail + 'journey.detail.backToJourney': 'Back to Journey', + 'journey.detail.syncedWithTrips': 'Synced with Trips', + 'journey.detail.addEntry': 'Add Entry', + 'journey.detail.newEntry': 'New Entry', + 'journey.detail.editEntry': 'Edit Entry', + 'journey.detail.noEntries': 'No entries yet', + 'journey.detail.noEntriesHint': 'Add a trip to get started with skeleton entries', + 'journey.detail.noPhotos': 'No photos yet', + 'journey.detail.noPhotosHint': 'Upload photos to entries or browse your Immich/Synology library', + 'journey.detail.journeyStats': 'Journey Stats', + 'journey.detail.syncedTrips': 'Synced Trips', + 'journey.detail.noTripsLinked': 'No trips linked yet', + 'journey.detail.contributors': 'Contributors', + 'journey.detail.readMore': 'Read more', + 'journey.detail.prosCons': 'Pros & Cons', + + // Journey Detail — Stats + 'journey.stats.days': 'Days', + 'journey.stats.cities': 'Cities', + 'journey.stats.entries': 'Entries', + 'journey.stats.photos': 'Photos', + 'journey.stats.places': 'Places', + + // Journey Detail — Verdict + 'journey.verdict.lovedIt': 'Loved it', + 'journey.verdict.couldBeBetter': 'Could be better', + + // Journey Detail — Synced badge + 'journey.synced.places': 'places', + 'journey.synced.synced': 'synced', + + // Journey Entry Editor + 'journey.editor.uploadPhotos': 'Upload photos', + 'journey.editor.fromGallery': 'From Gallery', + 'journey.editor.allPhotosAdded': 'All photos already added', + 'journey.editor.writeStory': 'Write your story...', + 'journey.editor.prosCons': 'Pros & Cons', + 'journey.editor.pros': 'Pros', + 'journey.editor.cons': 'Cons', + 'journey.editor.proPlaceholder': 'Something great...', + 'journey.editor.conPlaceholder': 'Not so great...', + 'journey.editor.addAnother': 'Add another', + 'journey.editor.date': 'Date', + 'journey.editor.location': 'Location', + 'journey.editor.searchLocation': 'Search location...', + 'journey.editor.mood': 'Mood', + 'journey.editor.weather': 'Weather', + 'journey.editor.photoFirst': '1st', + 'journey.editor.makeFirst': 'Make 1st', + + // Journey Entry — Moods + 'journey.mood.amazing': 'Amazing', + 'journey.mood.good': 'Good', + 'journey.mood.neutral': 'Neutral', + 'journey.mood.rough': 'Rough', + + // Journey Entry — Weather + 'journey.weather.sunny': 'Sunny', + 'journey.weather.partly': 'Partly cloudy', + 'journey.weather.cloudy': 'Cloudy', + 'journey.weather.rainy': 'Rainy', + 'journey.weather.stormy': 'Stormy', + 'journey.weather.cold': 'Snowy', + + // Journey — Trip Linking + 'journey.trips.linkTrip': 'Link Trip', + 'journey.trips.searchTrip': 'Search Trip', + 'journey.trips.searchPlaceholder': 'Trip name or destination...', + 'journey.trips.noTripsAvailable': 'No trips available', + 'journey.trips.link': 'Link', + 'journey.trips.tripLinked': 'Trip linked', + 'journey.trips.linkFailed': 'Failed to link trip', + 'journey.trips.addTrip': 'Add Trip', + 'journey.trips.unlinkTrip': 'Unlink Trip', + 'journey.trips.unlinkMessage': 'Unlink "{title}"? All synced entries and photos from this trip will be permanently deleted. This cannot be undone.', + 'journey.trips.unlink': 'Unlink', + 'journey.trips.tripUnlinked': 'Trip unlinked', + 'journey.trips.unlinkFailed': 'Failed to unlink trip', + 'journey.trips.noTripsLinkedSettings': 'No trips linked', + + // Journey — Contributors + 'journey.contributors.invite': 'Invite Contributor', + 'journey.contributors.searchUser': 'Search User', + 'journey.contributors.searchPlaceholder': 'Username or email...', + 'journey.contributors.noUsers': 'No users found', + 'journey.contributors.role': 'Role', + 'journey.contributors.added': 'Contributor added', + 'journey.contributors.addFailed': 'Failed to add contributor', + + // Journey — Share + 'journey.share.publicShare': 'Public Share', + 'journey.share.createLink': 'Create share link', + 'journey.share.linkCreated': 'Share link created', + 'journey.share.createFailed': 'Failed to create link', + 'journey.share.copy': 'Copy', + 'journey.share.copied': 'Copied!', + 'journey.share.timeline': 'Timeline', + 'journey.share.gallery': 'Gallery', + 'journey.share.map': 'Map', + 'journey.share.removeLink': 'Remove share link', + 'journey.share.linkDeleted': 'Share link deleted', + 'journey.share.deleteFailed': 'Failed to delete', + 'journey.share.updateFailed': 'Failed to update', + + // Journey — Settings Dialog + 'journey.settings.title': 'Journey Settings', + 'journey.settings.coverImage': 'Cover Image', + 'journey.settings.changeCover': 'Change cover', + 'journey.settings.addCover': 'Add cover image', + 'journey.settings.name': 'Name', + 'journey.settings.subtitle': 'Subtitle', + 'journey.settings.subtitlePlaceholder': 'e.g. Thailand, Vietnam & Cambodia', + 'journey.settings.delete': 'Delete', + 'journey.settings.deleteJourney': 'Delete Journey', + 'journey.settings.deleteMessage': 'Delete "{title}"? All entries and photos will be lost.', + 'journey.settings.saved': 'Settings saved', + 'journey.settings.saveFailed': 'Failed to save', + 'journey.settings.coverUpdated': 'Cover updated', + 'journey.settings.coverFailed': 'Upload failed', + + // Journey — Public Page + 'journey.public.notFound': 'Not Found', + 'journey.public.notFoundMessage': 'This journey doesn\'t exist or the link has expired.', + 'journey.public.readOnly': 'Read-only · Public Journey', + 'journey.public.tagline': 'Travel Resource & Exploration Kit', + 'journey.public.sharedVia': 'Shared via', + 'journey.public.madeWith': 'Made with', + + // Journey — PDF Export + 'journey.pdf.journeyBook': 'Journey Book', + 'journey.pdf.madeWith': 'Made with TREK', + 'journey.pdf.day': 'Day', + 'journey.pdf.theEnd': 'The End', + 'journey.pdf.saveAsPdf': 'Save as PDF', + 'journey.pdf.pages': 'pages', + + // Dashboard Mobile + 'dashboard.greeting.morning': 'Good morning,', + 'dashboard.greeting.afternoon': 'Good afternoon,', + 'dashboard.greeting.evening': 'Good evening,', + 'dashboard.mobile.liveNow': 'Live Now', + 'dashboard.mobile.tripProgress': 'Trip progress', + 'dashboard.mobile.daysLeft': '{count} days left', + 'dashboard.mobile.places': 'Places', + 'dashboard.mobile.buddies': 'Buddies', + 'dashboard.mobile.newTrip': 'New Trip', + 'dashboard.mobile.currency': 'Currency', + 'dashboard.mobile.timezone': 'Timezone', + 'dashboard.mobile.upcomingTrips': 'Upcoming Trips', + 'dashboard.mobile.yourTrips': 'Your Trips', + 'dashboard.mobile.trips': 'trips', + 'dashboard.mobile.starts': 'Starts', + 'dashboard.mobile.duration': 'Duration', + 'dashboard.mobile.day': 'day', + 'dashboard.mobile.days': 'days', + 'dashboard.mobile.ongoing': 'Ongoing', + 'dashboard.mobile.startsToday': 'Starts today', + 'dashboard.mobile.tomorrow': 'Tomorrow', + 'dashboard.mobile.inDays': 'In {count} days', + 'dashboard.mobile.inMonths': 'In {count} months', + 'dashboard.mobile.completed': 'Completed', + 'dashboard.mobile.currencyConverter': 'Currency Converter', + + // BottomNav & Profile + 'nav.profile': 'Profile', + 'nav.bottomSettings': 'Settings', + 'nav.bottomAdmin': 'Admin Settings', + 'nav.bottomLogout': 'Logout', + 'nav.bottomAdminBadge': 'Admin', + + // DayPlan Mobile + 'dayplan.mobile.addPlace': 'Add Place', + 'dayplan.mobile.searchPlaces': 'Search places...', + 'dayplan.mobile.allAssigned': 'All places assigned', + 'dayplan.mobile.noMatch': 'No match', + 'dayplan.mobile.createNew': 'Create new place', + + 'admin.addons.catalog.journey.name': 'Journey', + 'admin.addons.catalog.journey.description': 'Trip tracking & travel journal with check-ins, photos, and daily stories', } export default en diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 41219432..c9ac40d9 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -1692,6 +1692,239 @@ const es: Record = { 'notif.generic.text': 'Tienes una nueva notificación', 'notif.dev.unknown_event.title': '[DEV] Evento desconocido', 'notif.dev.unknown_event.text': 'El tipo de evento "{event}" no está registrado en EVENT_NOTIFICATION_CONFIG', + + // Journey, Dashboard, Nav, DayPlan + 'common.justNow': 'justo ahora', + 'common.hoursAgo': 'hace {count}h', + 'common.daysAgo': 'hace {count}d', + 'budget.linkedToReservation': 'Vinculado a una reserva — edita el nombre allí', + 'packing.saveAsTemplate': 'Guardar como plantilla', + 'packing.templateName': 'Nombre de la plantilla', + 'packing.templateSaved': 'Lista de equipaje guardada como plantilla', + 'memories.notConnectedMultipleHint': 'Conecta cualquiera de estos proveedores de fotos: {provider_names} en Ajustes para poder añadir fotos a este viaje.', + 'memories.providerUrl': 'URL del servidor', + 'memories.providerApiKey': 'Clave API', + 'memories.providerUsername': 'Nombre de usuario', + 'memories.providerPassword': 'Contraseña', + 'memories.saveError': 'No se pudo guardar la configuración de {provider_name}', + 'memories.selectAlbumMultiple': 'Seleccionar álbum', + 'memories.selectPhotosMultiple': 'Seleccionar fotos', + 'journey.title': 'Travesía', + 'journey.subtitle': 'Registra tus viajes en tiempo real', + 'journey.new': 'Nueva travesía', + 'journey.create': 'Crear', + 'journey.titlePlaceholder': '¿A dónde vas?', + 'journey.empty': 'Aún no hay travesías', + 'journey.emptyHint': 'Empieza a documentar tu próximo viaje', + 'journey.deleted': 'Travesía eliminada', + 'journey.createError': 'No se pudo crear la travesía', + 'journey.deleteError': 'No se pudo eliminar la travesía', + 'journey.deleteConfirmTitle': 'Eliminar', + 'journey.deleteConfirmMessage': '¿Eliminar "{title}"? Esta acción no se puede deshacer.', + 'journey.deleteConfirmGeneric': '¿Estás seguro de que quieres eliminar esto?', + 'journey.notFound': 'Travesía no encontrada', + 'journey.photos': 'Fotos', + 'journey.timelineEmpty': 'Aún no hay paradas', + 'journey.timelineEmptyHint': 'Añade un registro de ubicación o escribe una entrada de diario para empezar', + 'journey.status.draft': 'Borrador', + 'journey.status.active': 'Activa', + 'journey.status.completed': 'Completada', + 'journey.status.upcoming': 'Próxima', + 'journey.checkin.add': 'Registrar ubicación', + 'journey.checkin.namePlaceholder': 'Nombre del lugar', + 'journey.checkin.notesPlaceholder': 'Notas (opcional)', + 'journey.checkin.save': 'Guardar', + 'journey.checkin.error': 'No se pudo guardar el registro', + 'journey.entry.add': 'Diario', + 'journey.entry.edit': 'Editar entrada', + 'journey.entry.titlePlaceholder': 'Título (opcional)', + 'journey.entry.bodyPlaceholder': '¿Qué pasó hoy?', + 'journey.entry.save': 'Guardar', + 'journey.entry.error': 'No se pudo guardar la entrada', + 'journey.photo.add': 'Foto', + 'journey.photo.uploadError': 'Error al subir', + 'journey.share.share': 'Compartir', + 'journey.share.public': 'Público', + 'journey.share.linkCopied': 'Enlace público copiado', + 'journey.share.disabled': 'Compartir público desactivado', + 'journey.editor.titlePlaceholder': 'Dale un nombre a este momento...', + 'journey.editor.bodyPlaceholder': 'Cuenta la historia de este día...', + 'journey.editor.placePlaceholder': 'Ubicación (opcional)', + 'journey.editor.tagsPlaceholder': 'Etiquetas: joya oculta, mejor comida, hay que volver...', + 'journey.visibility.private': 'Privado', + 'journey.visibility.shared': 'Compartido', + 'journey.visibility.public': 'Público', + 'journey.emptyState.title': 'Tu historia empieza aquí', + 'journey.emptyState.subtitle': 'Registra una ubicación o escribe tu primera entrada de diario', + 'journey.frontpage.subtitle': 'Convierte tus viajes en historias que nunca olvidarás', + 'journey.frontpage.createJourney': 'Crear travesía', + 'journey.frontpage.activeJourney': 'Travesía activa', + 'journey.frontpage.allJourneys': 'Todas las travesías', + 'journey.frontpage.journeys': 'travesías', + 'journey.frontpage.createNew': 'Crear una nueva travesía', + 'journey.frontpage.createNewSub': 'Elige viajes, escribe historias, comparte tus aventuras', + 'journey.frontpage.live': 'En vivo', + 'journey.frontpage.synced': 'Sincronizado', + 'journey.frontpage.continueWriting': 'Seguir escribiendo', + 'journey.frontpage.updated': 'Actualizado {time}', + 'journey.frontpage.suggestionLabel': 'El viaje acaba de terminar', + 'journey.frontpage.suggestionText': 'Convierte {title} en una travesía', + 'journey.frontpage.dismiss': 'Descartar', + 'journey.frontpage.journeyName': 'Nombre de la travesía', + 'journey.frontpage.namePlaceholder': 'p. ej. Sudeste Asiático 2026', + 'journey.frontpage.selectTrips': 'Seleccionar viajes', + 'journey.frontpage.tripsSelected': 'viajes seleccionados', + 'journey.frontpage.trips': 'viajes', + 'journey.frontpage.placesImported': 'lugares serán importados', + 'journey.frontpage.places': 'lugares', + 'journey.detail.backToJourney': 'Volver a la travesía', + 'journey.detail.syncedWithTrips': 'Sincronizado con viajes', + 'journey.detail.addEntry': 'Añadir entrada', + 'journey.detail.newEntry': 'Nueva entrada', + 'journey.detail.editEntry': 'Editar entrada', + 'journey.detail.noEntries': 'Aún no hay entradas', + 'journey.detail.noEntriesHint': 'Añade un viaje para empezar con entradas preliminares', + 'journey.detail.noPhotos': 'Aún no hay fotos', + 'journey.detail.noPhotosHint': 'Sube fotos a las entradas o explora tu biblioteca de Immich/Synology', + 'journey.detail.journeyStats': 'Estadísticas de la travesía', + 'journey.detail.syncedTrips': 'Viajes sincronizados', + 'journey.detail.noTripsLinked': 'Aún no hay viajes vinculados', + 'journey.detail.contributors': 'Colaboradores', + 'journey.detail.readMore': 'Leer más', + 'journey.detail.prosCons': 'Pros y contras', + 'journey.stats.days': 'Días', + 'journey.stats.cities': 'Ciudades', + 'journey.stats.entries': 'Entradas', + 'journey.stats.photos': 'Fotos', + 'journey.stats.places': 'Lugares', + 'journey.verdict.lovedIt': 'Me encantó', + 'journey.verdict.couldBeBetter': 'Podría mejorar', + 'journey.synced.places': 'lugares', + 'journey.synced.synced': 'sincronizado', + 'journey.editor.uploadPhotos': 'Subir fotos', + 'journey.editor.fromGallery': 'Desde galería', + 'journey.editor.allPhotosAdded': 'Todas las fotos ya fueron añadidas', + 'journey.editor.writeStory': 'Escribe tu historia...', + 'journey.editor.prosCons': 'Pros y contras', + 'journey.editor.pros': 'Pros', + 'journey.editor.cons': 'Contras', + 'journey.editor.proPlaceholder': 'Algo genial...', + 'journey.editor.conPlaceholder': 'No tan genial...', + 'journey.editor.addAnother': 'Añadir otro', + 'journey.editor.date': 'Fecha', + 'journey.editor.location': 'Ubicación', + 'journey.editor.searchLocation': 'Buscar ubicación...', + 'journey.editor.mood': 'Estado de ánimo', + 'journey.editor.weather': 'Clima', + 'journey.editor.photoFirst': '1º', + 'journey.editor.makeFirst': 'Hacer 1º', + 'journey.mood.amazing': 'Increíble', + 'journey.mood.good': 'Bien', + 'journey.mood.neutral': 'Neutral', + 'journey.mood.rough': 'Difícil', + 'journey.weather.sunny': 'Soleado', + 'journey.weather.partly': 'Parcialmente nublado', + 'journey.weather.cloudy': 'Nublado', + 'journey.weather.rainy': 'Lluvioso', + 'journey.weather.stormy': 'Tormentoso', + 'journey.weather.cold': 'Nevado', + 'journey.trips.linkTrip': 'Vincular viaje', + 'journey.trips.searchTrip': 'Buscar viaje', + 'journey.trips.searchPlaceholder': 'Nombre del viaje o destino...', + 'journey.trips.noTripsAvailable': 'No hay viajes disponibles', + 'journey.trips.link': 'Vincular', + 'journey.trips.tripLinked': 'Viaje vinculado', + 'journey.trips.linkFailed': 'No se pudo vincular el viaje', + 'journey.trips.addTrip': 'Añadir viaje', + 'journey.trips.unlinkTrip': 'Desvincular viaje', + 'journey.trips.unlinkMessage': '¿Desvincular "{title}"? Todas las entradas y fotos sincronizadas de este viaje se eliminarán permanentemente. Esta acción no se puede deshacer.', + 'journey.trips.unlink': 'Desvincular', + 'journey.trips.tripUnlinked': 'Viaje desvinculado', + 'journey.trips.unlinkFailed': 'No se pudo desvincular el viaje', + 'journey.trips.noTripsLinkedSettings': 'No hay viajes vinculados', + 'journey.contributors.invite': 'Invitar colaborador', + 'journey.contributors.searchUser': 'Buscar usuario', + 'journey.contributors.searchPlaceholder': 'Nombre de usuario o correo...', + 'journey.contributors.noUsers': 'No se encontraron usuarios', + 'journey.contributors.role': 'Rol', + 'journey.contributors.added': 'Colaborador añadido', + 'journey.contributors.addFailed': 'No se pudo añadir al colaborador', + 'journey.share.publicShare': 'Compartir público', + 'journey.share.createLink': 'Crear enlace para compartir', + 'journey.share.linkCreated': 'Enlace para compartir creado', + 'journey.share.createFailed': 'No se pudo crear el enlace', + 'journey.share.copy': 'Copiar', + 'journey.share.copied': '¡Copiado!', + 'journey.share.timeline': 'Cronología', + 'journey.share.gallery': 'Galería', + 'journey.share.map': 'Mapa', + 'journey.share.removeLink': 'Eliminar enlace para compartir', + 'journey.share.linkDeleted': 'Enlace para compartir eliminado', + 'journey.share.deleteFailed': 'No se pudo eliminar', + 'journey.share.updateFailed': 'No se pudo actualizar', + 'journey.settings.title': 'Ajustes de la travesía', + 'journey.settings.coverImage': 'Imagen de portada', + 'journey.settings.changeCover': 'Cambiar portada', + 'journey.settings.addCover': 'Añadir imagen de portada', + 'journey.settings.name': 'Nombre', + 'journey.settings.subtitle': 'Subtítulo', + 'journey.settings.subtitlePlaceholder': 'p. ej. Tailandia, Vietnam y Camboya', + 'journey.settings.delete': 'Eliminar', + 'journey.settings.deleteJourney': 'Eliminar travesía', + 'journey.settings.deleteMessage': '¿Eliminar "{title}"? Todas las entradas y fotos se perderán.', + 'journey.settings.saved': 'Ajustes guardados', + 'journey.settings.saveFailed': 'No se pudo guardar', + 'journey.settings.coverUpdated': 'Portada actualizada', + 'journey.settings.coverFailed': 'Error al subir', + 'journey.public.notFound': 'No encontrado', + 'journey.public.notFoundMessage': 'Esta travesía no existe o el enlace ha expirado.', + 'journey.public.readOnly': 'Solo lectura · Travesía pública', + 'journey.public.tagline': 'Kit de recursos y exploración de viajes', + 'journey.public.sharedVia': 'Compartido mediante', + 'journey.public.madeWith': 'Hecho con', + 'journey.pdf.journeyBook': 'Libro de travesía', + 'journey.pdf.madeWith': 'Hecho con TREK', + 'journey.pdf.day': 'Día', + 'journey.pdf.theEnd': 'Fin', + 'journey.pdf.saveAsPdf': 'Guardar como PDF', + 'journey.pdf.pages': 'páginas', + 'dashboard.greeting.morning': 'Buenos días,', + 'dashboard.greeting.afternoon': 'Buenas tardes,', + 'dashboard.greeting.evening': 'Buenas noches,', + 'dashboard.mobile.liveNow': 'En vivo ahora', + 'dashboard.mobile.tripProgress': 'Progreso del viaje', + 'dashboard.mobile.daysLeft': '{count} días restantes', + 'dashboard.mobile.places': 'Lugares', + 'dashboard.mobile.buddies': 'Compañeros', + 'dashboard.mobile.newTrip': 'Nuevo viaje', + 'dashboard.mobile.currency': 'Moneda', + 'dashboard.mobile.timezone': 'Zona horaria', + 'dashboard.mobile.upcomingTrips': 'Próximos viajes', + 'dashboard.mobile.yourTrips': 'Tus viajes', + 'dashboard.mobile.trips': 'viajes', + 'dashboard.mobile.starts': 'Comienza', + 'dashboard.mobile.duration': 'Duración', + 'dashboard.mobile.day': 'día', + 'dashboard.mobile.days': 'días', + 'dashboard.mobile.ongoing': 'En curso', + 'dashboard.mobile.startsToday': 'Comienza hoy', + 'dashboard.mobile.tomorrow': 'Mañana', + 'dashboard.mobile.inDays': 'En {count} días', + 'dashboard.mobile.inMonths': 'En {count} meses', + 'dashboard.mobile.completed': 'Completado', + 'dashboard.mobile.currencyConverter': 'Conversor de monedas', + 'nav.profile': 'Perfil', + 'nav.bottomSettings': 'Ajustes', + 'nav.bottomAdmin': 'Administración', + 'nav.bottomLogout': 'Cerrar sesión', + 'nav.bottomAdminBadge': 'Admin', + 'dayplan.mobile.addPlace': 'Añadir lugar', + 'dayplan.mobile.searchPlaces': 'Buscar lugares...', + 'dayplan.mobile.allAssigned': 'Todos los lugares asignados', + 'dayplan.mobile.noMatch': 'Sin coincidencias', + 'dayplan.mobile.createNew': 'Crear nuevo lugar', + 'admin.addons.catalog.journey.name': 'Travesía', + 'admin.addons.catalog.journey.description': 'Seguimiento de viajes y diario de viajero con registros de ubicación, fotos e historias diarias', } export default es diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index cbc2e09c..91708d28 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -1686,6 +1686,239 @@ const fr: Record = { 'notif.generic.text': 'Vous avez une nouvelle notification', 'notif.dev.unknown_event.title': '[DEV] Événement inconnu', 'notif.dev.unknown_event.text': 'Le type d\'événement "{event}" n\'est pas enregistré dans EVENT_NOTIFICATION_CONFIG', + + // Journey, Dashboard, Nav, DayPlan + 'common.justNow': 'à l\'instant', + 'common.hoursAgo': 'il y a {count}h', + 'common.daysAgo': 'il y a {count}j', + 'budget.linkedToReservation': 'Lié à une réservation — modifiez le nom là-bas', + 'packing.saveAsTemplate': 'Enregistrer comme modèle', + 'packing.templateName': 'Nom du modèle', + 'packing.templateSaved': 'Liste de bagages enregistrée comme modèle', + 'memories.notConnectedMultipleHint': 'Connectez l\'un de ces fournisseurs de photos : {provider_names} dans les Paramètres pour pouvoir ajouter des photos à ce voyage.', + 'memories.providerUrl': 'URL du serveur', + 'memories.providerApiKey': 'Clé API', + 'memories.providerUsername': 'Nom d\'utilisateur', + 'memories.providerPassword': 'Mot de passe', + 'memories.saveError': 'Impossible d\'enregistrer les paramètres de {provider_name}', + 'memories.selectAlbumMultiple': 'Sélectionner un album', + 'memories.selectPhotosMultiple': 'Sélectionner des photos', + 'journey.title': 'Journal de voyage', + 'journey.subtitle': 'Suivez vos voyages en temps réel', + 'journey.new': 'Nouveau journal', + 'journey.create': 'Créer', + 'journey.titlePlaceholder': 'Où allez-vous ?', + 'journey.empty': 'Aucun journal pour le moment', + 'journey.emptyHint': 'Commencez à documenter votre prochain voyage', + 'journey.deleted': 'Journal supprimé', + 'journey.createError': 'Impossible de créer le journal', + 'journey.deleteError': 'Impossible de supprimer le journal', + 'journey.deleteConfirmTitle': 'Supprimer', + 'journey.deleteConfirmMessage': 'Supprimer « {title} » ? Cette action est irréversible.', + 'journey.deleteConfirmGeneric': 'Êtes-vous sûr de vouloir supprimer ceci ?', + 'journey.notFound': 'Journal introuvable', + 'journey.photos': 'Photos', + 'journey.timelineEmpty': 'Aucune étape pour le moment', + 'journey.timelineEmptyHint': 'Ajoutez un check-in ou écrivez une entrée de journal pour commencer', + 'journey.status.draft': 'Brouillon', + 'journey.status.active': 'Actif', + 'journey.status.completed': 'Terminé', + 'journey.status.upcoming': 'À venir', + 'journey.checkin.add': 'Check-in', + 'journey.checkin.namePlaceholder': 'Nom du lieu', + 'journey.checkin.notesPlaceholder': 'Notes (facultatif)', + 'journey.checkin.save': 'Enregistrer', + 'journey.checkin.error': 'Impossible d\'enregistrer le check-in', + 'journey.entry.add': 'Journal', + 'journey.entry.edit': 'Modifier l\'entrée', + 'journey.entry.titlePlaceholder': 'Titre (facultatif)', + 'journey.entry.bodyPlaceholder': 'Que s\'est-il passé aujourd\'hui ?', + 'journey.entry.save': 'Enregistrer', + 'journey.entry.error': 'Impossible d\'enregistrer l\'entrée', + 'journey.photo.add': 'Photo', + 'journey.photo.uploadError': 'Échec du téléversement', + 'journey.share.share': 'Partager', + 'journey.share.public': 'Public', + 'journey.share.linkCopied': 'Lien public copié', + 'journey.share.disabled': 'Partage public désactivé', + 'journey.editor.titlePlaceholder': 'Donnez un nom à ce moment...', + 'journey.editor.bodyPlaceholder': 'Racontez l\'histoire de cette journée...', + 'journey.editor.placePlaceholder': 'Lieu (facultatif)', + 'journey.editor.tagsPlaceholder': 'Tags : pépite cachée, meilleur repas, à revisiter...', + 'journey.visibility.private': 'Privé', + 'journey.visibility.shared': 'Partagé', + 'journey.visibility.public': 'Public', + 'journey.emptyState.title': 'Votre histoire commence ici', + 'journey.emptyState.subtitle': 'Faites un check-in ou écrivez votre première entrée de journal', + 'journey.frontpage.subtitle': 'Transformez vos voyages en histoires inoubliables', + 'journey.frontpage.createJourney': 'Créer un journal', + 'journey.frontpage.activeJourney': 'Journal actif', + 'journey.frontpage.allJourneys': 'Tous les journaux', + 'journey.frontpage.journeys': 'journaux', + 'journey.frontpage.createNew': 'Créer un nouveau journal', + 'journey.frontpage.createNewSub': 'Choisissez des voyages, écrivez des récits, partagez vos aventures', + 'journey.frontpage.live': 'En direct', + 'journey.frontpage.synced': 'Synchronisé', + 'journey.frontpage.continueWriting': 'Continuer à écrire', + 'journey.frontpage.updated': 'Mis à jour {time}', + 'journey.frontpage.suggestionLabel': 'Voyage terminé récemment', + 'journey.frontpage.suggestionText': 'Transformez {title} en journal de voyage', + 'journey.frontpage.dismiss': 'Ignorer', + 'journey.frontpage.journeyName': 'Nom du journal', + 'journey.frontpage.namePlaceholder': 'ex. Asie du Sud-Est 2026', + 'journey.frontpage.selectTrips': 'Sélectionner des voyages', + 'journey.frontpage.tripsSelected': 'voyages sélectionnés', + 'journey.frontpage.trips': 'voyages', + 'journey.frontpage.placesImported': 'lieux seront importés', + 'journey.frontpage.places': 'lieux', + 'journey.detail.backToJourney': 'Retour au journal', + 'journey.detail.syncedWithTrips': 'Synchronisé avec les voyages', + 'journey.detail.addEntry': 'Ajouter une entrée', + 'journey.detail.newEntry': 'Nouvelle entrée', + 'journey.detail.editEntry': 'Modifier l\'entrée', + 'journey.detail.noEntries': 'Aucune entrée pour le moment', + 'journey.detail.noEntriesHint': 'Ajoutez un voyage pour commencer avec des entrées préremplies', + 'journey.detail.noPhotos': 'Aucune photo pour le moment', + 'journey.detail.noPhotosHint': 'Téléversez des photos dans les entrées ou parcourez votre bibliothèque Immich/Synology', + 'journey.detail.journeyStats': 'Statistiques du journal', + 'journey.detail.syncedTrips': 'Voyages synchronisés', + 'journey.detail.noTripsLinked': 'Aucun voyage lié pour le moment', + 'journey.detail.contributors': 'Contributeurs', + 'journey.detail.readMore': 'Lire la suite', + 'journey.detail.prosCons': 'Pour et contre', + 'journey.stats.days': 'Jours', + 'journey.stats.cities': 'Villes', + 'journey.stats.entries': 'Entrées', + 'journey.stats.photos': 'Photos', + 'journey.stats.places': 'Lieux', + 'journey.verdict.lovedIt': 'Adoré', + 'journey.verdict.couldBeBetter': 'Pourrait être mieux', + 'journey.synced.places': 'lieux', + 'journey.synced.synced': 'synchronisé', + 'journey.editor.uploadPhotos': 'Téléverser des photos', + 'journey.editor.fromGallery': 'Depuis la galerie', + 'journey.editor.allPhotosAdded': 'Toutes les photos ont déjà été ajoutées', + 'journey.editor.writeStory': 'Écrivez votre histoire...', + 'journey.editor.prosCons': 'Pour et contre', + 'journey.editor.pros': 'Pour', + 'journey.editor.cons': 'Contre', + 'journey.editor.proPlaceholder': 'Quelque chose de génial...', + 'journey.editor.conPlaceholder': 'Pas si génial...', + 'journey.editor.addAnother': 'Ajouter un autre', + 'journey.editor.date': 'Date', + 'journey.editor.location': 'Lieu', + 'journey.editor.searchLocation': 'Rechercher un lieu...', + 'journey.editor.mood': 'Humeur', + 'journey.editor.weather': 'Météo', + 'journey.editor.photoFirst': '1er', + 'journey.editor.makeFirst': 'Mettre en 1er', + 'journey.mood.amazing': 'Incroyable', + 'journey.mood.good': 'Bien', + 'journey.mood.neutral': 'Neutre', + 'journey.mood.rough': 'Difficile', + 'journey.weather.sunny': 'Ensoleillé', + 'journey.weather.partly': 'Partiellement nuageux', + 'journey.weather.cloudy': 'Nuageux', + 'journey.weather.rainy': 'Pluvieux', + 'journey.weather.stormy': 'Orageux', + 'journey.weather.cold': 'Neigeux', + 'journey.trips.linkTrip': 'Lier un voyage', + 'journey.trips.searchTrip': 'Rechercher un voyage', + 'journey.trips.searchPlaceholder': 'Nom du voyage ou destination...', + 'journey.trips.noTripsAvailable': 'Aucun voyage disponible', + 'journey.trips.link': 'Lier', + 'journey.trips.tripLinked': 'Voyage lié', + 'journey.trips.linkFailed': 'Échec de la liaison du voyage', + 'journey.trips.addTrip': 'Ajouter un voyage', + 'journey.trips.unlinkTrip': 'Délier le voyage', + 'journey.trips.unlinkMessage': 'Délier « {title} » ? Toutes les entrées et photos synchronisées de ce voyage seront définitivement supprimées. Cette action est irréversible.', + 'journey.trips.unlink': 'Délier', + 'journey.trips.tripUnlinked': 'Voyage délié', + 'journey.trips.unlinkFailed': 'Échec de la suppression du lien', + 'journey.trips.noTripsLinkedSettings': 'Aucun voyage lié', + 'journey.contributors.invite': 'Inviter un contributeur', + 'journey.contributors.searchUser': 'Rechercher un utilisateur', + 'journey.contributors.searchPlaceholder': 'Nom d\'utilisateur ou e-mail...', + 'journey.contributors.noUsers': 'Aucun utilisateur trouvé', + 'journey.contributors.role': 'Rôle', + 'journey.contributors.added': 'Contributeur ajouté', + 'journey.contributors.addFailed': 'Échec de l\'ajout du contributeur', + 'journey.share.publicShare': 'Partage public', + 'journey.share.createLink': 'Créer un lien de partage', + 'journey.share.linkCreated': 'Lien de partage créé', + 'journey.share.createFailed': 'Échec de la création du lien', + 'journey.share.copy': 'Copier', + 'journey.share.copied': 'Copié !', + 'journey.share.timeline': 'Chronologie', + 'journey.share.gallery': 'Galerie', + 'journey.share.map': 'Carte', + 'journey.share.removeLink': 'Supprimer le lien de partage', + 'journey.share.linkDeleted': 'Lien de partage supprimé', + 'journey.share.deleteFailed': 'Échec de la suppression', + 'journey.share.updateFailed': 'Échec de la mise à jour', + 'journey.settings.title': 'Paramètres du journal', + 'journey.settings.coverImage': 'Image de couverture', + 'journey.settings.changeCover': 'Changer la couverture', + 'journey.settings.addCover': 'Ajouter une image de couverture', + 'journey.settings.name': 'Nom', + 'journey.settings.subtitle': 'Sous-titre', + 'journey.settings.subtitlePlaceholder': 'ex. Thaïlande, Vietnam et Cambodge', + 'journey.settings.delete': 'Supprimer', + 'journey.settings.deleteJourney': 'Supprimer le journal', + 'journey.settings.deleteMessage': 'Supprimer « {title} » ? Toutes les entrées et photos seront perdues.', + 'journey.settings.saved': 'Paramètres enregistrés', + 'journey.settings.saveFailed': 'Échec de l\'enregistrement', + 'journey.settings.coverUpdated': 'Couverture mise à jour', + 'journey.settings.coverFailed': 'Échec du téléversement', + 'journey.public.notFound': 'Introuvable', + 'journey.public.notFoundMessage': 'Ce journal n\'existe pas ou le lien a expiré.', + 'journey.public.readOnly': 'Lecture seule · Journal public', + 'journey.public.tagline': 'Travel Resource & Exploration Kit', + 'journey.public.sharedVia': 'Partagé via', + 'journey.public.madeWith': 'Créé avec', + 'journey.pdf.journeyBook': 'Carnet de voyage', + 'journey.pdf.madeWith': 'Créé avec TREK', + 'journey.pdf.day': 'Jour', + 'journey.pdf.theEnd': 'Fin', + 'journey.pdf.saveAsPdf': 'Enregistrer en PDF', + 'journey.pdf.pages': 'pages', + 'dashboard.greeting.morning': 'Bonjour,', + 'dashboard.greeting.afternoon': 'Bon après-midi,', + 'dashboard.greeting.evening': 'Bonsoir,', + 'dashboard.mobile.liveNow': 'En direct', + 'dashboard.mobile.tripProgress': 'Progression du voyage', + 'dashboard.mobile.daysLeft': '{count} jours restants', + 'dashboard.mobile.places': 'Lieux', + 'dashboard.mobile.buddies': 'Compagnons', + 'dashboard.mobile.newTrip': 'Nouveau voyage', + 'dashboard.mobile.currency': 'Devise', + 'dashboard.mobile.timezone': 'Fuseau horaire', + 'dashboard.mobile.upcomingTrips': 'Voyages à venir', + 'dashboard.mobile.yourTrips': 'Vos voyages', + 'dashboard.mobile.trips': 'voyages', + 'dashboard.mobile.starts': 'Début', + 'dashboard.mobile.duration': 'Durée', + 'dashboard.mobile.day': 'jour', + 'dashboard.mobile.days': 'jours', + 'dashboard.mobile.ongoing': 'En cours', + 'dashboard.mobile.startsToday': 'Commence aujourd\'hui', + 'dashboard.mobile.tomorrow': 'Demain', + 'dashboard.mobile.inDays': 'Dans {count} jours', + 'dashboard.mobile.inMonths': 'Dans {count} mois', + 'dashboard.mobile.completed': 'Terminé', + 'dashboard.mobile.currencyConverter': 'Convertisseur de devises', + 'nav.profile': 'Profil', + 'nav.bottomSettings': 'Paramètres', + 'nav.bottomAdmin': 'Administration', + 'nav.bottomLogout': 'Déconnexion', + 'nav.bottomAdminBadge': 'Admin', + 'dayplan.mobile.addPlace': 'Ajouter un lieu', + 'dayplan.mobile.searchPlaces': 'Rechercher des lieux...', + 'dayplan.mobile.allAssigned': 'Tous les lieux attribués', + 'dayplan.mobile.noMatch': 'Aucun résultat', + 'dayplan.mobile.createNew': 'Créer un nouveau lieu', + 'admin.addons.catalog.journey.name': 'Journal de voyage', + 'admin.addons.catalog.journey.description': 'Suivi de voyages et journal avec check-ins, photos et récits quotidiens', } export default fr diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 40ce49a2..42c6d460 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -1687,6 +1687,239 @@ const hu: Record = { 'notif.generic.text': 'Új értesítésed érkezett', 'notif.dev.unknown_event.title': '[DEV] Ismeretlen esemény', 'notif.dev.unknown_event.text': 'A(z) "{event}" eseménytípus nincs regisztrálva az EVENT_NOTIFICATION_CONFIG-ban', + + // Journey, Dashboard, Nav, DayPlan + 'common.justNow': 'az imént', + 'common.hoursAgo': '{count} órája', + 'common.daysAgo': '{count} napja', + 'budget.linkedToReservation': 'Foglaláshoz kapcsolva — a nevet ott módosítsd', + 'packing.saveAsTemplate': 'Mentés sablonként', + 'packing.templateName': 'Sablon neve', + 'packing.templateSaved': 'Csomaglista sablonként mentve', + 'memories.notConnectedMultipleHint': 'Csatlakoztasd valamelyik fotószolgáltatót: {provider_names} a Beállításokban, hogy fotókat adhass hozzá ehhez az úthoz.', + 'memories.providerUrl': 'Szerver URL', + 'memories.providerApiKey': 'API-kulcs', + 'memories.providerUsername': 'Felhasználónév', + 'memories.providerPassword': 'Jelszó', + 'memories.saveError': 'Nem sikerült menteni a(z) {provider_name} beállításait', + 'memories.selectAlbumMultiple': 'Album kiválasztása', + 'memories.selectPhotosMultiple': 'Fotók kiválasztása', + 'journey.title': 'Útinaplók', + 'journey.subtitle': 'Kövesse nyomon utazásait valós időben', + 'journey.new': 'Új útinapló', + 'journey.create': 'Létrehozás', + 'journey.titlePlaceholder': 'Hová utazol?', + 'journey.empty': 'Még nincsenek útinaplók', + 'journey.emptyHint': 'Kezdd el dokumentálni a következő utazásod', + 'journey.deleted': 'Útinapló törölve', + 'journey.createError': 'Nem sikerült létrehozni az útinaplót', + 'journey.deleteError': 'Nem sikerült törölni az útinaplót', + 'journey.deleteConfirmTitle': 'Törlés', + 'journey.deleteConfirmMessage': 'Törlöd a(z) „{title}" útinaplót? Ez nem vonható vissza.', + 'journey.deleteConfirmGeneric': 'Biztosan törölni szeretnéd?', + 'journey.notFound': 'Útinapló nem található', + 'journey.photos': 'Fotók', + 'journey.timelineEmpty': 'Még nincsenek megállók', + 'journey.timelineEmptyHint': 'Adj hozzá egy bejelentkezést vagy írj naplóbejegyzést a kezdéshez', + 'journey.status.draft': 'Vázlat', + 'journey.status.active': 'Aktív', + 'journey.status.completed': 'Befejezett', + 'journey.status.upcoming': 'Közelgő', + 'journey.checkin.add': 'Bejelentkezés', + 'journey.checkin.namePlaceholder': 'Helyszín neve', + 'journey.checkin.notesPlaceholder': 'Jegyzetek (opcionális)', + 'journey.checkin.save': 'Mentés', + 'journey.checkin.error': 'Nem sikerült menteni a bejelentkezést', + 'journey.entry.add': 'Napló', + 'journey.entry.edit': 'Bejegyzés szerkesztése', + 'journey.entry.titlePlaceholder': 'Cím (opcionális)', + 'journey.entry.bodyPlaceholder': 'Mi történt ma?', + 'journey.entry.save': 'Mentés', + 'journey.entry.error': 'Nem sikerült menteni a bejegyzést', + 'journey.photo.add': 'Fotó', + 'journey.photo.uploadError': 'A feltöltés sikertelen', + 'journey.share.share': 'Megosztás', + 'journey.share.public': 'Nyilvános', + 'journey.share.linkCopied': 'Nyilvános link másolva', + 'journey.share.disabled': 'Nyilvános megosztás letiltva', + 'journey.editor.titlePlaceholder': 'Adj nevet ennek a pillanatnak...', + 'journey.editor.bodyPlaceholder': 'Meséld el ennek a napnak a történetét...', + 'journey.editor.placePlaceholder': 'Helyszín (opcionális)', + 'journey.editor.tagsPlaceholder': 'Címkék: rejtett kincs, legjobb étel, újra meglátogatandó...', + 'journey.visibility.private': 'Privát', + 'journey.visibility.shared': 'Megosztott', + 'journey.visibility.public': 'Nyilvános', + 'journey.emptyState.title': 'Itt kezdődik a történeted', + 'journey.emptyState.subtitle': 'Jelentkezz be egy helyszínen vagy írd meg az első naplóbejegyzésed', + 'journey.frontpage.subtitle': 'Alakítsd utazásaidat történetekké, amelyeket soha nem felejtesz el', + 'journey.frontpage.createJourney': 'Útinapló létrehozása', + 'journey.frontpage.activeJourney': 'Aktív útinapló', + 'journey.frontpage.allJourneys': 'Összes útinapló', + 'journey.frontpage.journeys': 'útinapló', + 'journey.frontpage.createNew': 'Új útinapló létrehozása', + 'journey.frontpage.createNewSub': 'Válassz utakat, írj történeteket, oszd meg kalandjaidat', + 'journey.frontpage.live': 'Élő', + 'journey.frontpage.synced': 'Szinkronizálva', + 'journey.frontpage.continueWriting': 'Írás folytatása', + 'journey.frontpage.updated': 'Frissítve: {time}', + 'journey.frontpage.suggestionLabel': 'Az út épp véget ért', + 'journey.frontpage.suggestionText': 'Alakítsd a(z) {title} útinaplóvá', + 'journey.frontpage.dismiss': 'Elvetés', + 'journey.frontpage.journeyName': 'Útinapló neve', + 'journey.frontpage.namePlaceholder': 'pl. Délkelet-Ázsia 2026', + 'journey.frontpage.selectTrips': 'Utak kiválasztása', + 'journey.frontpage.tripsSelected': 'út kiválasztva', + 'journey.frontpage.trips': 'út', + 'journey.frontpage.placesImported': 'helyszín importálásra kerül', + 'journey.frontpage.places': 'helyszín', + 'journey.detail.backToJourney': 'Vissza az útinaplóhoz', + 'journey.detail.syncedWithTrips': 'Szinkronizálva az utakkal', + 'journey.detail.addEntry': 'Bejegyzés hozzáadása', + 'journey.detail.newEntry': 'Új bejegyzés', + 'journey.detail.editEntry': 'Bejegyzés szerkesztése', + 'journey.detail.noEntries': 'Még nincsenek bejegyzések', + 'journey.detail.noEntriesHint': 'Adj hozzá egy utat a vázlatos bejegyzések elkészítéséhez', + 'journey.detail.noPhotos': 'Még nincsenek fotók', + 'journey.detail.noPhotosHint': 'Tölts fel fotókat a bejegyzésekhez vagy böngészd az Immich/Synology könyvtárat', + 'journey.detail.journeyStats': 'Útinapló statisztika', + 'journey.detail.syncedTrips': 'Szinkronizált utak', + 'journey.detail.noTripsLinked': 'Még nincsenek kapcsolt utak', + 'journey.detail.contributors': 'Közreműködők', + 'journey.detail.readMore': 'Tovább olvasás', + 'journey.detail.prosCons': 'Előnyök és hátrányok', + 'journey.stats.days': 'Napok', + 'journey.stats.cities': 'Városok', + 'journey.stats.entries': 'Bejegyzések', + 'journey.stats.photos': 'Fotók', + 'journey.stats.places': 'Helyszínek', + 'journey.verdict.lovedIt': 'Imádtam', + 'journey.verdict.couldBeBetter': 'Lehetne jobb', + 'journey.synced.places': 'helyszín', + 'journey.synced.synced': 'szinkronizálva', + 'journey.editor.uploadPhotos': 'Fotók feltöltése', + 'journey.editor.fromGallery': 'Galériából', + 'journey.editor.allPhotosAdded': 'Minden fotó már hozzáadva', + 'journey.editor.writeStory': 'Írd meg a történeted...', + 'journey.editor.prosCons': 'Előnyök és hátrányok', + 'journey.editor.pros': 'Előnyök', + 'journey.editor.cons': 'Hátrányok', + 'journey.editor.proPlaceholder': 'Valami remek...', + 'journey.editor.conPlaceholder': 'Nem annyira jó...', + 'journey.editor.addAnother': 'Még egy hozzáadása', + 'journey.editor.date': 'Dátum', + 'journey.editor.location': 'Helyszín', + 'journey.editor.searchLocation': 'Helyszín keresése...', + 'journey.editor.mood': 'Hangulat', + 'journey.editor.weather': 'Időjárás', + 'journey.editor.photoFirst': '1.', + 'journey.editor.makeFirst': 'Legyen az 1.', + 'journey.mood.amazing': 'Fantasztikus', + 'journey.mood.good': 'Jó', + 'journey.mood.neutral': 'Semleges', + 'journey.mood.rough': 'Nehéz', + 'journey.weather.sunny': 'Napos', + 'journey.weather.partly': 'Részben felhős', + 'journey.weather.cloudy': 'Felhős', + 'journey.weather.rainy': 'Esős', + 'journey.weather.stormy': 'Viharos', + 'journey.weather.cold': 'Havas', + 'journey.trips.linkTrip': 'Út kapcsolása', + 'journey.trips.searchTrip': 'Út keresése', + 'journey.trips.searchPlaceholder': 'Út neve vagy úti cél...', + 'journey.trips.noTripsAvailable': 'Nincsenek elérhető utak', + 'journey.trips.link': 'Kapcsolás', + 'journey.trips.tripLinked': 'Út kapcsolva', + 'journey.trips.linkFailed': 'Nem sikerült az utat kapcsolni', + 'journey.trips.addTrip': 'Út hozzáadása', + 'journey.trips.unlinkTrip': 'Út leválasztása', + 'journey.trips.unlinkMessage': 'Leválasztod a(z) „{title}" utat? Az összes szinkronizált bejegyzés és fotó véglegesen törlődik. Ez nem vonható vissza.', + 'journey.trips.unlink': 'Leválasztás', + 'journey.trips.tripUnlinked': 'Út leválasztva', + 'journey.trips.unlinkFailed': 'Nem sikerült az utat leválasztani', + 'journey.trips.noTripsLinkedSettings': 'Nincsenek kapcsolt utak', + 'journey.contributors.invite': 'Közreműködő meghívása', + 'journey.contributors.searchUser': 'Felhasználó keresése', + 'journey.contributors.searchPlaceholder': 'Felhasználónév vagy e-mail...', + 'journey.contributors.noUsers': 'Nem található felhasználó', + 'journey.contributors.role': 'Szerep', + 'journey.contributors.added': 'Közreműködő hozzáadva', + 'journey.contributors.addFailed': 'Nem sikerült hozzáadni a közreműködőt', + 'journey.share.publicShare': 'Nyilvános megosztás', + 'journey.share.createLink': 'Megosztó link létrehozása', + 'journey.share.linkCreated': 'Megosztó link létrehozva', + 'journey.share.createFailed': 'Nem sikerült létrehozni a linket', + 'journey.share.copy': 'Másolás', + 'journey.share.copied': 'Másolva!', + 'journey.share.timeline': 'Idővonal', + 'journey.share.gallery': 'Galéria', + 'journey.share.map': 'Térkép', + 'journey.share.removeLink': 'Megosztó link eltávolítása', + 'journey.share.linkDeleted': 'Megosztó link törölve', + 'journey.share.deleteFailed': 'Nem sikerült törölni', + 'journey.share.updateFailed': 'Nem sikerült frissíteni', + 'journey.settings.title': 'Útinapló beállításai', + 'journey.settings.coverImage': 'Borítókép', + 'journey.settings.changeCover': 'Borító módosítása', + 'journey.settings.addCover': 'Borítókép hozzáadása', + 'journey.settings.name': 'Név', + 'journey.settings.subtitle': 'Alcím', + 'journey.settings.subtitlePlaceholder': 'pl. Thaiföld, Vietnam és Kambodzsa', + '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.', + 'journey.settings.saved': 'Beállítások mentve', + 'journey.settings.saveFailed': 'Nem sikerült menteni', + 'journey.settings.coverUpdated': 'Borítókép frissítve', + 'journey.settings.coverFailed': 'A feltöltés sikertelen', + 'journey.public.notFound': 'Nem található', + 'journey.public.notFoundMessage': 'Ez az útinapló nem létezik vagy a link lejárt.', + 'journey.public.readOnly': 'Csak olvasható · Nyilvános útinapló', + 'journey.public.tagline': 'Utazástervező és felfedező eszköz', + 'journey.public.sharedVia': 'Megosztva a következőn keresztül:', + 'journey.public.madeWith': 'Készítve a következővel:', + 'journey.pdf.journeyBook': 'Útinaplókönyv', + 'journey.pdf.madeWith': 'Készítve a TREK segítségével', + 'journey.pdf.day': 'Nap', + 'journey.pdf.theEnd': 'Vége', + 'journey.pdf.saveAsPdf': 'Mentés PDF-ként', + 'journey.pdf.pages': 'oldal', + 'dashboard.greeting.morning': 'Jó reggelt,', + 'dashboard.greeting.afternoon': 'Jó napot,', + 'dashboard.greeting.evening': 'Jó estét,', + 'dashboard.mobile.liveNow': 'Most élőben', + 'dashboard.mobile.tripProgress': 'Út előrehaladása', + 'dashboard.mobile.daysLeft': 'még {count} nap', + 'dashboard.mobile.places': 'Helyszínek', + 'dashboard.mobile.buddies': 'Útitársak', + 'dashboard.mobile.newTrip': 'Új út', + 'dashboard.mobile.currency': 'Pénznem', + 'dashboard.mobile.timezone': 'Időzóna', + 'dashboard.mobile.upcomingTrips': 'Közelgő utak', + 'dashboard.mobile.yourTrips': 'Utaid', + 'dashboard.mobile.trips': 'út', + 'dashboard.mobile.starts': 'Kezdés', + 'dashboard.mobile.duration': 'Időtartam', + 'dashboard.mobile.day': 'nap', + 'dashboard.mobile.days': 'nap', + 'dashboard.mobile.ongoing': 'Folyamatban', + 'dashboard.mobile.startsToday': 'Ma kezdődik', + 'dashboard.mobile.tomorrow': 'Holnap', + 'dashboard.mobile.inDays': '{count} nap múlva', + 'dashboard.mobile.inMonths': '{count} hónap múlva', + 'dashboard.mobile.completed': 'Befejezett', + 'dashboard.mobile.currencyConverter': 'Pénznemváltó', + 'nav.profile': 'Profil', + 'nav.bottomSettings': 'Beállítások', + 'nav.bottomAdmin': 'Adminisztráció', + 'nav.bottomLogout': 'Kijelentkezés', + 'nav.bottomAdminBadge': 'Admin', + 'dayplan.mobile.addPlace': 'Helyszín hozzáadása', + 'dayplan.mobile.searchPlaces': 'Helyszínek keresése...', + 'dayplan.mobile.allAssigned': 'Minden helyszín kiosztva', + 'dayplan.mobile.noMatch': 'Nincs találat', + 'dayplan.mobile.createNew': 'Új helyszín létrehozása', + 'admin.addons.catalog.journey.name': 'Útinaplók', + 'admin.addons.catalog.journey.description': 'Utazáskövetés és útinapló bejelentkezésekkel, fotókkal és napi történetekkel', } export default hu diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index d5449ff8..541d69d0 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -1687,6 +1687,239 @@ const it: Record = { 'notif.generic.text': 'Hai una nuova notifica', 'notif.dev.unknown_event.title': '[DEV] Evento sconosciuto', 'notif.dev.unknown_event.text': 'Il tipo di evento "{event}" non è registrato in EVENT_NOTIFICATION_CONFIG', + + // Journey, Dashboard, Nav, DayPlan + 'common.justNow': 'proprio ora', + 'common.hoursAgo': '{count}h fa', + 'common.daysAgo': '{count}g fa', + 'budget.linkedToReservation': 'Collegato a una prenotazione — modifica il nome lì', + 'packing.saveAsTemplate': 'Salva come modello', + 'packing.templateName': 'Nome del modello', + 'packing.templateSaved': 'Lista bagagli salvata come modello', + 'memories.notConnectedMultipleHint': 'Collega uno di questi fornitori di foto: {provider_names} nelle Impostazioni per poter aggiungere foto a questo viaggio.', + 'memories.providerUrl': 'URL del server', + 'memories.providerApiKey': 'Chiave API', + 'memories.providerUsername': 'Nome utente', + 'memories.providerPassword': 'Password', + 'memories.saveError': 'Impossibile salvare le impostazioni di {provider_name}', + 'memories.selectAlbumMultiple': 'Seleziona album', + 'memories.selectPhotosMultiple': 'Seleziona foto', + 'journey.title': 'Diario di viaggio', + 'journey.subtitle': 'Segui i tuoi viaggi in tempo reale', + 'journey.new': 'Nuovo diario', + 'journey.create': 'Crea', + 'journey.titlePlaceholder': 'Dove stai andando?', + 'journey.empty': 'Nessun diario ancora', + 'journey.emptyHint': 'Inizia a documentare il tuo prossimo viaggio', + 'journey.deleted': 'Diario eliminato', + 'journey.createError': 'Impossibile creare il diario', + 'journey.deleteError': 'Impossibile eliminare il diario', + 'journey.deleteConfirmTitle': 'Elimina', + 'journey.deleteConfirmMessage': 'Eliminare "{title}"? Questa azione non può essere annullata.', + 'journey.deleteConfirmGeneric': 'Sei sicuro di voler eliminare questo?', + 'journey.notFound': 'Diario non trovato', + 'journey.photos': 'Foto', + 'journey.timelineEmpty': 'Nessuna tappa ancora', + 'journey.timelineEmptyHint': 'Aggiungi un check-in o scrivi una voce di diario per iniziare', + 'journey.status.draft': 'Bozza', + 'journey.status.active': 'Attivo', + 'journey.status.completed': 'Completato', + 'journey.status.upcoming': 'In arrivo', + 'journey.checkin.add': 'Check-in', + 'journey.checkin.namePlaceholder': 'Nome del luogo', + 'journey.checkin.notesPlaceholder': 'Note (facoltativo)', + 'journey.checkin.save': 'Salva', + 'journey.checkin.error': 'Impossibile salvare il check-in', + 'journey.entry.add': 'Diario', + 'journey.entry.edit': 'Modifica voce', + 'journey.entry.titlePlaceholder': 'Titolo (facoltativo)', + 'journey.entry.bodyPlaceholder': 'Cosa è successo oggi?', + 'journey.entry.save': 'Salva', + 'journey.entry.error': 'Impossibile salvare la voce', + 'journey.photo.add': 'Foto', + 'journey.photo.uploadError': 'Caricamento fallito', + 'journey.share.share': 'Condividi', + 'journey.share.public': 'Pubblico', + 'journey.share.linkCopied': 'Link pubblico copiato', + 'journey.share.disabled': 'Condivisione pubblica disattivata', + 'journey.editor.titlePlaceholder': 'Dai un nome a questo momento...', + 'journey.editor.bodyPlaceholder': 'Racconta la storia di questa giornata...', + 'journey.editor.placePlaceholder': 'Luogo (facoltativo)', + 'journey.editor.tagsPlaceholder': 'Tag: gioiello nascosto, miglior pasto, da rivisitare...', + 'journey.visibility.private': 'Privato', + 'journey.visibility.shared': 'Condiviso', + 'journey.visibility.public': 'Pubblico', + 'journey.emptyState.title': 'La tua storia inizia qui', + 'journey.emptyState.subtitle': 'Fai un check-in o scrivi la tua prima voce di diario', + 'journey.frontpage.subtitle': 'Trasforma i tuoi viaggi in storie indimenticabili', + 'journey.frontpage.createJourney': 'Crea diario', + 'journey.frontpage.activeJourney': 'Diario attivo', + 'journey.frontpage.allJourneys': 'Tutti i diari', + 'journey.frontpage.journeys': 'diari', + 'journey.frontpage.createNew': 'Crea un nuovo diario', + 'journey.frontpage.createNewSub': 'Scegli viaggi, scrivi storie, condividi le tue avventure', + 'journey.frontpage.live': 'In diretta', + 'journey.frontpage.synced': 'Sincronizzato', + 'journey.frontpage.continueWriting': 'Continua a scrivere', + 'journey.frontpage.updated': 'Aggiornato {time}', + 'journey.frontpage.suggestionLabel': 'Viaggio appena terminato', + 'journey.frontpage.suggestionText': 'Trasforma {title} in un diario di viaggio', + 'journey.frontpage.dismiss': 'Ignora', + 'journey.frontpage.journeyName': 'Nome del diario', + 'journey.frontpage.namePlaceholder': 'es. Sud-est asiatico 2026', + 'journey.frontpage.selectTrips': 'Seleziona viaggi', + 'journey.frontpage.tripsSelected': 'viaggi selezionati', + 'journey.frontpage.trips': 'viaggi', + 'journey.frontpage.placesImported': 'luoghi saranno importati', + 'journey.frontpage.places': 'luoghi', + 'journey.detail.backToJourney': 'Torna al diario', + 'journey.detail.syncedWithTrips': 'Sincronizzato con i viaggi', + 'journey.detail.addEntry': 'Aggiungi voce', + 'journey.detail.newEntry': 'Nuova voce', + 'journey.detail.editEntry': 'Modifica voce', + 'journey.detail.noEntries': 'Nessuna voce ancora', + 'journey.detail.noEntriesHint': 'Aggiungi un viaggio per iniziare con voci precompilate', + 'journey.detail.noPhotos': 'Nessuna foto ancora', + 'journey.detail.noPhotosHint': 'Carica foto nelle voci o sfoglia la tua libreria Immich/Synology', + 'journey.detail.journeyStats': 'Statistiche del diario', + 'journey.detail.syncedTrips': 'Viaggi sincronizzati', + 'journey.detail.noTripsLinked': 'Nessun viaggio collegato ancora', + 'journey.detail.contributors': 'Contributori', + 'journey.detail.readMore': 'Leggi di più', + 'journey.detail.prosCons': 'Pro e contro', + 'journey.stats.days': 'Giorni', + 'journey.stats.cities': 'Città', + 'journey.stats.entries': 'Voci', + 'journey.stats.photos': 'Foto', + 'journey.stats.places': 'Luoghi', + 'journey.verdict.lovedIt': 'Adorato', + 'journey.verdict.couldBeBetter': 'Potrebbe essere meglio', + 'journey.synced.places': 'luoghi', + 'journey.synced.synced': 'sincronizzato', + 'journey.editor.uploadPhotos': 'Carica foto', + 'journey.editor.fromGallery': 'Dalla galleria', + 'journey.editor.allPhotosAdded': 'Tutte le foto sono già state aggiunte', + 'journey.editor.writeStory': 'Scrivi la tua storia...', + 'journey.editor.prosCons': 'Pro e contro', + 'journey.editor.pros': 'Pro', + 'journey.editor.cons': 'Contro', + 'journey.editor.proPlaceholder': 'Qualcosa di fantastico...', + 'journey.editor.conPlaceholder': 'Non così fantastico...', + 'journey.editor.addAnother': 'Aggiungi un altro', + 'journey.editor.date': 'Data', + 'journey.editor.location': 'Luogo', + 'journey.editor.searchLocation': 'Cerca luogo...', + 'journey.editor.mood': 'Umore', + 'journey.editor.weather': 'Meteo', + 'journey.editor.photoFirst': '1°', + 'journey.editor.makeFirst': 'Metti 1°', + 'journey.mood.amazing': 'Fantastico', + 'journey.mood.good': 'Buono', + 'journey.mood.neutral': 'Neutro', + 'journey.mood.rough': 'Difficile', + 'journey.weather.sunny': 'Soleggiato', + 'journey.weather.partly': 'Parzialmente nuvoloso', + 'journey.weather.cloudy': 'Nuvoloso', + 'journey.weather.rainy': 'Piovoso', + 'journey.weather.stormy': 'Temporalesco', + 'journey.weather.cold': 'Nevoso', + 'journey.trips.linkTrip': 'Collega viaggio', + 'journey.trips.searchTrip': 'Cerca viaggio', + 'journey.trips.searchPlaceholder': 'Nome del viaggio o destinazione...', + 'journey.trips.noTripsAvailable': 'Nessun viaggio disponibile', + 'journey.trips.link': 'Collega', + 'journey.trips.tripLinked': 'Viaggio collegato', + 'journey.trips.linkFailed': 'Collegamento del viaggio fallito', + 'journey.trips.addTrip': 'Aggiungi viaggio', + 'journey.trips.unlinkTrip': 'Scollega viaggio', + 'journey.trips.unlinkMessage': 'Scollegare "{title}"? Tutte le voci e le foto sincronizzate da questo viaggio saranno eliminate definitivamente. Questa azione non può essere annullata.', + 'journey.trips.unlink': 'Scollega', + 'journey.trips.tripUnlinked': 'Viaggio scollegato', + 'journey.trips.unlinkFailed': 'Scollegamento del viaggio fallito', + 'journey.trips.noTripsLinkedSettings': 'Nessun viaggio collegato', + 'journey.contributors.invite': 'Invita contributore', + 'journey.contributors.searchUser': 'Cerca utente', + 'journey.contributors.searchPlaceholder': 'Nome utente o e-mail...', + 'journey.contributors.noUsers': 'Nessun utente trovato', + 'journey.contributors.role': 'Ruolo', + 'journey.contributors.added': 'Contributore aggiunto', + 'journey.contributors.addFailed': 'Impossibile aggiungere il contributore', + 'journey.share.publicShare': 'Condivisione pubblica', + 'journey.share.createLink': 'Crea link di condivisione', + 'journey.share.linkCreated': 'Link di condivisione creato', + 'journey.share.createFailed': 'Creazione del link fallita', + 'journey.share.copy': 'Copia', + 'journey.share.copied': 'Copiato!', + 'journey.share.timeline': 'Cronologia', + 'journey.share.gallery': 'Galleria', + 'journey.share.map': 'Mappa', + 'journey.share.removeLink': 'Rimuovi link di condivisione', + 'journey.share.linkDeleted': 'Link di condivisione eliminato', + 'journey.share.deleteFailed': 'Eliminazione fallita', + 'journey.share.updateFailed': 'Aggiornamento fallito', + 'journey.settings.title': 'Impostazioni del diario', + 'journey.settings.coverImage': 'Immagine di copertina', + 'journey.settings.changeCover': 'Cambia copertina', + 'journey.settings.addCover': 'Aggiungi immagine di copertina', + 'journey.settings.name': 'Nome', + 'journey.settings.subtitle': 'Sottotitolo', + 'journey.settings.subtitlePlaceholder': 'es. Thailandia, Vietnam e Cambogia', + 'journey.settings.delete': 'Elimina', + 'journey.settings.deleteJourney': 'Elimina diario', + 'journey.settings.deleteMessage': 'Eliminare "{title}"? Tutte le voci e le foto andranno perse.', + 'journey.settings.saved': 'Impostazioni salvate', + 'journey.settings.saveFailed': 'Salvataggio fallito', + 'journey.settings.coverUpdated': 'Copertina aggiornata', + 'journey.settings.coverFailed': 'Caricamento fallito', + 'journey.public.notFound': 'Non trovato', + 'journey.public.notFoundMessage': 'Questo diario non esiste o il link è scaduto.', + 'journey.public.readOnly': 'Sola lettura · Diario pubblico', + 'journey.public.tagline': 'Travel Resource & Exploration Kit', + 'journey.public.sharedVia': 'Condiviso tramite', + 'journey.public.madeWith': 'Creato con', + 'journey.pdf.journeyBook': 'Diario di viaggio', + 'journey.pdf.madeWith': 'Creato con TREK', + 'journey.pdf.day': 'Giorno', + 'journey.pdf.theEnd': 'Fine', + 'journey.pdf.saveAsPdf': 'Salva come PDF', + 'journey.pdf.pages': 'pagine', + 'dashboard.greeting.morning': 'Buongiorno,', + 'dashboard.greeting.afternoon': 'Buon pomeriggio,', + 'dashboard.greeting.evening': 'Buonasera,', + 'dashboard.mobile.liveNow': 'In diretta', + 'dashboard.mobile.tripProgress': 'Progresso del viaggio', + 'dashboard.mobile.daysLeft': '{count} giorni rimanenti', + 'dashboard.mobile.places': 'Luoghi', + 'dashboard.mobile.buddies': 'Compagni', + 'dashboard.mobile.newTrip': 'Nuovo viaggio', + 'dashboard.mobile.currency': 'Valuta', + 'dashboard.mobile.timezone': 'Fuso orario', + 'dashboard.mobile.upcomingTrips': 'Viaggi in arrivo', + 'dashboard.mobile.yourTrips': 'I tuoi viaggi', + 'dashboard.mobile.trips': 'viaggi', + 'dashboard.mobile.starts': 'Inizio', + 'dashboard.mobile.duration': 'Durata', + 'dashboard.mobile.day': 'giorno', + 'dashboard.mobile.days': 'giorni', + 'dashboard.mobile.ongoing': 'In corso', + 'dashboard.mobile.startsToday': 'Inizia oggi', + 'dashboard.mobile.tomorrow': 'Domani', + 'dashboard.mobile.inDays': 'Tra {count} giorni', + 'dashboard.mobile.inMonths': 'Tra {count} mesi', + 'dashboard.mobile.completed': 'Completato', + 'dashboard.mobile.currencyConverter': 'Convertitore di valuta', + 'nav.profile': 'Profilo', + 'nav.bottomSettings': 'Impostazioni', + 'nav.bottomAdmin': 'Amministrazione', + 'nav.bottomLogout': 'Disconnetti', + 'nav.bottomAdminBadge': 'Admin', + 'dayplan.mobile.addPlace': 'Aggiungi luogo', + 'dayplan.mobile.searchPlaces': 'Cerca luoghi...', + 'dayplan.mobile.allAssigned': 'Tutti i luoghi assegnati', + 'dayplan.mobile.noMatch': 'Nessun risultato', + 'dayplan.mobile.createNew': 'Crea nuovo luogo', + 'admin.addons.catalog.journey.name': 'Diario di viaggio', + 'admin.addons.catalog.journey.description': 'Tracciamento viaggi e diario con check-in, foto e storie quotidiane', } export default it diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 2e7495da..9b7a4d68 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -1686,6 +1686,239 @@ const nl: Record = { 'notif.generic.text': 'Je hebt een nieuwe melding', 'notif.dev.unknown_event.title': '[DEV] Onbekende gebeurtenis', 'notif.dev.unknown_event.text': 'Gebeurtenistype "{event}" is niet geregistreerd in EVENT_NOTIFICATION_CONFIG', + + // Journey, Dashboard, Nav, DayPlan + 'common.justNow': 'zojuist', + 'common.hoursAgo': '{count}u geleden', + 'common.daysAgo': '{count}d geleden', + 'budget.linkedToReservation': 'Gekoppeld aan een reservering — bewerk de naam daar', + 'packing.saveAsTemplate': 'Opslaan als sjabloon', + 'packing.templateName': 'Sjabloonnaam', + 'packing.templateSaved': 'Paklijst opgeslagen als sjabloon', + 'memories.notConnectedMultipleHint': 'Verbind een van deze foto-aanbieders: {provider_names} in Instellingen om foto\'s aan deze reis toe te voegen.', + 'memories.providerUrl': 'Server-URL', + 'memories.providerApiKey': 'API-sleutel', + 'memories.providerUsername': 'Gebruikersnaam', + 'memories.providerPassword': 'Wachtwoord', + 'memories.saveError': 'Kon {provider_name}-instellingen niet opslaan', + 'memories.selectAlbumMultiple': 'Selecteer album', + 'memories.selectPhotosMultiple': 'Selecteer foto\'s', + 'journey.title': 'Reisverslag', + 'journey.subtitle': 'Leg je reizen vast terwijl je onderweg bent', + 'journey.new': 'Nieuw reisverslag', + 'journey.create': 'Aanmaken', + 'journey.titlePlaceholder': 'Waar ga je naartoe?', + 'journey.empty': 'Nog geen reisverslagen', + 'journey.emptyHint': 'Begin met het vastleggen van je volgende reis', + 'journey.deleted': 'Reisverslag verwijderd', + 'journey.createError': 'Kon reisverslag niet aanmaken', + 'journey.deleteError': 'Kon reisverslag niet verwijderen', + 'journey.deleteConfirmTitle': 'Verwijderen', + 'journey.deleteConfirmMessage': '"{title}" verwijderen? Dit kan niet ongedaan worden gemaakt.', + 'journey.deleteConfirmGeneric': 'Weet je zeker dat je dit wilt verwijderen?', + 'journey.notFound': 'Reisverslag niet gevonden', + 'journey.photos': 'Foto\'s', + 'journey.timelineEmpty': 'Nog geen stops', + 'journey.timelineEmptyHint': 'Voeg een check-in toe of schrijf een dagboekvermelding om te beginnen', + 'journey.status.draft': 'Concept', + 'journey.status.active': 'Actief', + 'journey.status.completed': 'Voltooid', + 'journey.status.upcoming': 'Gepland', + 'journey.checkin.add': 'Inchecken', + 'journey.checkin.namePlaceholder': 'Locatienaam', + 'journey.checkin.notesPlaceholder': 'Notities (optioneel)', + 'journey.checkin.save': 'Opslaan', + 'journey.checkin.error': 'Kon check-in niet opslaan', + 'journey.entry.add': 'Dagboek', + 'journey.entry.edit': 'Vermelding bewerken', + 'journey.entry.titlePlaceholder': 'Titel (optioneel)', + 'journey.entry.bodyPlaceholder': 'Wat is er vandaag gebeurd?', + 'journey.entry.save': 'Opslaan', + 'journey.entry.error': 'Kon vermelding niet opslaan', + 'journey.photo.add': 'Foto', + 'journey.photo.uploadError': 'Uploaden mislukt', + 'journey.share.share': 'Delen', + 'journey.share.public': 'Openbaar', + 'journey.share.linkCopied': 'Openbare link gekopieerd', + 'journey.share.disabled': 'Openbaar delen uitgeschakeld', + 'journey.editor.titlePlaceholder': 'Geef dit moment een naam...', + 'journey.editor.bodyPlaceholder': 'Vertel het verhaal van deze dag...', + 'journey.editor.placePlaceholder': 'Locatie (optioneel)', + 'journey.editor.tagsPlaceholder': 'Tags: verborgen parel, beste maaltijd, moet terugkomen...', + 'journey.visibility.private': 'Privé', + 'journey.visibility.shared': 'Gedeeld', + 'journey.visibility.public': 'Openbaar', + 'journey.emptyState.title': 'Je verhaal begint hier', + 'journey.emptyState.subtitle': 'Check in op een plek of schrijf je eerste dagboekvermelding', + 'journey.frontpage.subtitle': 'Maak van je reizen verhalen die je nooit vergeet', + 'journey.frontpage.createJourney': 'Reisverslag aanmaken', + 'journey.frontpage.activeJourney': 'Actief reisverslag', + 'journey.frontpage.allJourneys': 'Alle reisverslagen', + 'journey.frontpage.journeys': 'reisverslagen', + 'journey.frontpage.createNew': 'Nieuw reisverslag aanmaken', + 'journey.frontpage.createNewSub': 'Kies reizen, schrijf verhalen, deel je avonturen', + 'journey.frontpage.live': 'Live', + 'journey.frontpage.synced': 'Gesynchroniseerd', + 'journey.frontpage.continueWriting': 'Verder schrijven', + 'journey.frontpage.updated': 'Bijgewerkt {time}', + 'journey.frontpage.suggestionLabel': 'Reis net afgelopen', + 'journey.frontpage.suggestionText': 'Maak van {title} een reisverslag', + 'journey.frontpage.dismiss': 'Sluiten', + 'journey.frontpage.journeyName': 'Naam reisverslag', + 'journey.frontpage.namePlaceholder': 'bijv. Zuidoost-Azië 2026', + 'journey.frontpage.selectTrips': 'Selecteer reizen', + 'journey.frontpage.tripsSelected': 'reizen geselecteerd', + 'journey.frontpage.trips': 'reizen', + 'journey.frontpage.placesImported': 'plaatsen worden geïmporteerd', + 'journey.frontpage.places': 'plaatsen', + 'journey.detail.backToJourney': 'Terug naar reisverslag', + 'journey.detail.syncedWithTrips': 'Gesynchroniseerd met reizen', + 'journey.detail.addEntry': 'Vermelding toevoegen', + 'journey.detail.newEntry': 'Nieuwe vermelding', + 'journey.detail.editEntry': 'Vermelding bewerken', + 'journey.detail.noEntries': 'Nog geen vermeldingen', + 'journey.detail.noEntriesHint': 'Voeg een reis toe om te beginnen met skeletvermeldingen', + 'journey.detail.noPhotos': 'Nog geen foto\'s', + 'journey.detail.noPhotosHint': 'Upload foto\'s naar vermeldingen of blader door je Immich/Synology-bibliotheek', + 'journey.detail.journeyStats': 'Reisstatistieken', + 'journey.detail.syncedTrips': 'Gesynchroniseerde reizen', + 'journey.detail.noTripsLinked': 'Nog geen reizen gekoppeld', + 'journey.detail.contributors': 'Bijdragers', + 'journey.detail.readMore': 'Lees meer', + 'journey.detail.prosCons': 'Voor- & nadelen', + 'journey.stats.days': 'Dagen', + 'journey.stats.cities': 'Steden', + 'journey.stats.entries': 'Vermeldingen', + 'journey.stats.photos': 'Foto\'s', + 'journey.stats.places': 'Plaatsen', + 'journey.verdict.lovedIt': 'Geweldig', + 'journey.verdict.couldBeBetter': 'Kan beter', + 'journey.synced.places': 'plaatsen', + 'journey.synced.synced': 'gesynchroniseerd', + 'journey.editor.uploadPhotos': 'Foto\'s uploaden', + 'journey.editor.fromGallery': 'Uit galerij', + 'journey.editor.allPhotosAdded': 'Alle foto\'s al toegevoegd', + 'journey.editor.writeStory': 'Schrijf je verhaal...', + 'journey.editor.prosCons': 'Voor- & nadelen', + 'journey.editor.pros': 'Voordelen', + 'journey.editor.cons': 'Nadelen', + 'journey.editor.proPlaceholder': 'Iets geweldigs...', + 'journey.editor.conPlaceholder': 'Niet zo geweldig...', + 'journey.editor.addAnother': 'Nog een toevoegen', + 'journey.editor.date': 'Datum', + 'journey.editor.location': 'Locatie', + 'journey.editor.searchLocation': 'Locatie zoeken...', + 'journey.editor.mood': 'Stemming', + 'journey.editor.weather': 'Weer', + 'journey.editor.photoFirst': '1e', + 'journey.editor.makeFirst': 'Maak 1e', + 'journey.mood.amazing': 'Fantastisch', + 'journey.mood.good': 'Goed', + 'journey.mood.neutral': 'Neutraal', + 'journey.mood.rough': 'Zwaar', + 'journey.weather.sunny': 'Zonnig', + 'journey.weather.partly': 'Halfbewolkt', + 'journey.weather.cloudy': 'Bewolkt', + 'journey.weather.rainy': 'Regenachtig', + 'journey.weather.stormy': 'Stormachtig', + 'journey.weather.cold': 'Sneeuw', + 'journey.trips.linkTrip': 'Reis koppelen', + 'journey.trips.searchTrip': 'Reis zoeken', + 'journey.trips.searchPlaceholder': 'Reisnaam of bestemming...', + 'journey.trips.noTripsAvailable': 'Geen reizen beschikbaar', + 'journey.trips.link': 'Koppelen', + 'journey.trips.tripLinked': 'Reis gekoppeld', + 'journey.trips.linkFailed': 'Koppelen van reis mislukt', + 'journey.trips.addTrip': 'Reis toevoegen', + 'journey.trips.unlinkTrip': 'Reis ontkoppelen', + 'journey.trips.unlinkMessage': '"{title}" ontkoppelen? Alle gesynchroniseerde vermeldingen en foto\'s van deze reis worden permanent verwijderd. Dit kan niet ongedaan worden gemaakt.', + 'journey.trips.unlink': 'Ontkoppelen', + 'journey.trips.tripUnlinked': 'Reis ontkoppeld', + 'journey.trips.unlinkFailed': 'Ontkoppelen van reis mislukt', + 'journey.trips.noTripsLinkedSettings': 'Geen reizen gekoppeld', + 'journey.contributors.invite': 'Bijdrager uitnodigen', + 'journey.contributors.searchUser': 'Gebruiker zoeken', + 'journey.contributors.searchPlaceholder': 'Gebruikersnaam of e-mail...', + 'journey.contributors.noUsers': 'Geen gebruikers gevonden', + 'journey.contributors.role': 'Rol', + 'journey.contributors.added': 'Bijdrager toegevoegd', + 'journey.contributors.addFailed': 'Toevoegen van bijdrager mislukt', + 'journey.share.publicShare': 'Openbaar delen', + 'journey.share.createLink': 'Deellink aanmaken', + 'journey.share.linkCreated': 'Deellink aangemaakt', + 'journey.share.createFailed': 'Aanmaken van link mislukt', + 'journey.share.copy': 'Kopiëren', + 'journey.share.copied': 'Gekopieerd!', + 'journey.share.timeline': 'Tijdlijn', + 'journey.share.gallery': 'Galerij', + 'journey.share.map': 'Kaart', + 'journey.share.removeLink': 'Deellink verwijderen', + 'journey.share.linkDeleted': 'Deellink verwijderd', + 'journey.share.deleteFailed': 'Verwijderen mislukt', + 'journey.share.updateFailed': 'Bijwerken mislukt', + 'journey.settings.title': 'Reisverslaginstellingen', + 'journey.settings.coverImage': 'Omslagfoto', + 'journey.settings.changeCover': 'Omslag wijzigen', + 'journey.settings.addCover': 'Omslagfoto toevoegen', + 'journey.settings.name': 'Naam', + 'journey.settings.subtitle': 'Ondertitel', + 'journey.settings.subtitlePlaceholder': 'bijv. Thailand, Vietnam & Cambodja', + 'journey.settings.delete': 'Verwijderen', + 'journey.settings.deleteJourney': 'Reisverslag verwijderen', + 'journey.settings.deleteMessage': '"{title}" verwijderen? Alle vermeldingen en foto\'s gaan verloren.', + 'journey.settings.saved': 'Instellingen opgeslagen', + 'journey.settings.saveFailed': 'Opslaan mislukt', + 'journey.settings.coverUpdated': 'Omslag bijgewerkt', + 'journey.settings.coverFailed': 'Uploaden mislukt', + 'journey.public.notFound': 'Niet gevonden', + 'journey.public.notFoundMessage': 'Dit reisverslag bestaat niet of de link is verlopen.', + 'journey.public.readOnly': 'Alleen-lezen · Openbaar reisverslag', + 'journey.public.tagline': 'Travel Resource & Exploration Kit', + 'journey.public.sharedVia': 'Gedeeld via', + 'journey.public.madeWith': 'Gemaakt met', + 'journey.pdf.journeyBook': 'Reisboek', + 'journey.pdf.madeWith': 'Gemaakt met TREK', + 'journey.pdf.day': 'Dag', + 'journey.pdf.theEnd': 'Einde', + 'journey.pdf.saveAsPdf': 'Opslaan als PDF', + 'journey.pdf.pages': 'pagina\'s', + 'dashboard.greeting.morning': 'Goedemorgen,', + 'dashboard.greeting.afternoon': 'Goedemiddag,', + 'dashboard.greeting.evening': 'Goedenavond,', + 'dashboard.mobile.liveNow': 'Nu live', + 'dashboard.mobile.tripProgress': 'Reisvoortgang', + 'dashboard.mobile.daysLeft': '{count} dagen over', + 'dashboard.mobile.places': 'Plaatsen', + 'dashboard.mobile.buddies': 'Reisgenoten', + 'dashboard.mobile.newTrip': 'Nieuwe reis', + 'dashboard.mobile.currency': 'Valuta', + 'dashboard.mobile.timezone': 'Tijdzone', + 'dashboard.mobile.upcomingTrips': 'Aankomende reizen', + 'dashboard.mobile.yourTrips': 'Jouw reizen', + 'dashboard.mobile.trips': 'reizen', + 'dashboard.mobile.starts': 'Begint', + 'dashboard.mobile.duration': 'Duur', + 'dashboard.mobile.day': 'dag', + 'dashboard.mobile.days': 'dagen', + 'dashboard.mobile.ongoing': 'Bezig', + 'dashboard.mobile.startsToday': 'Begint vandaag', + 'dashboard.mobile.tomorrow': 'Morgen', + 'dashboard.mobile.inDays': 'Over {count} dagen', + 'dashboard.mobile.inMonths': 'Over {count} maanden', + 'dashboard.mobile.completed': 'Voltooid', + 'dashboard.mobile.currencyConverter': 'Valutaomrekener', + 'nav.profile': 'Profiel', + 'nav.bottomSettings': 'Instellingen', + 'nav.bottomAdmin': 'Beheerdersinstellingen', + 'nav.bottomLogout': 'Uitloggen', + 'nav.bottomAdminBadge': 'Beheerder', + 'dayplan.mobile.addPlace': 'Plaats toevoegen', + 'dayplan.mobile.searchPlaces': 'Plaatsen zoeken...', + 'dayplan.mobile.allAssigned': 'Alle plaatsen toegewezen', + 'dayplan.mobile.noMatch': 'Geen resultaat', + 'dayplan.mobile.createNew': 'Nieuwe plaats aanmaken', + 'admin.addons.catalog.journey.name': 'Reisverslag', + 'admin.addons.catalog.journey.description': 'Reistracking & reisdagboek met check-ins, foto\'s en dagelijkse verhalen', } export default nl diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 4f60b983..afe4e971 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -1679,6 +1679,239 @@ const pl: Record = { 'notif.generic.text': 'Masz nowe powiadomienie', 'notif.dev.unknown_event.title': '[DEV] Nieznane zdarzenie', 'notif.dev.unknown_event.text': 'Typ zdarzenia "{event}" nie jest zarejestrowany w EVENT_NOTIFICATION_CONFIG', + + // Journey, Dashboard, Nav, DayPlan + 'common.justNow': 'przed chwilą', + 'common.hoursAgo': '{count} godz. temu', + 'common.daysAgo': '{count} dn. temu', + 'budget.linkedToReservation': 'Powiązane z rezerwacją — edytuj nazwę tam', + 'packing.saveAsTemplate': 'Zapisz jako szablon', + 'packing.templateName': 'Nazwa szablonu', + 'packing.templateSaved': 'Lista pakowania zapisana jako szablon', + 'memories.notConnectedMultipleHint': 'Połącz jednego z tych dostawców zdjęć: {provider_names} w Ustawieniach, aby dodawać zdjęcia do tej podróży.', + 'memories.providerUrl': 'Adres URL serwera', + 'memories.providerApiKey': 'Klucz API', + 'memories.providerUsername': 'Nazwa użytkownika', + 'memories.providerPassword': 'Hasło', + 'memories.saveError': 'Nie udało się zapisać ustawień {provider_name}', + 'memories.selectAlbumMultiple': 'Wybierz album', + 'memories.selectPhotosMultiple': 'Wybierz zdjęcia', + 'journey.title': 'Dziennik podróży', + 'journey.subtitle': 'Dokumentuj swoje podróże na bieżąco', + 'journey.new': 'Nowy dziennik podróży', + 'journey.create': 'Utwórz', + 'journey.titlePlaceholder': 'Dokąd jedziesz?', + 'journey.empty': 'Brak dzienników podróży', + 'journey.emptyHint': 'Zacznij dokumentować swoją następną podróż', + 'journey.deleted': 'Dziennik podróży usunięty', + 'journey.createError': 'Nie udało się utworzyć dziennika podróży', + 'journey.deleteError': 'Nie udało się usunąć dziennika podróży', + 'journey.deleteConfirmTitle': 'Usuń', + 'journey.deleteConfirmMessage': 'Usunąć „{title}"? Tej operacji nie można cofnąć.', + 'journey.deleteConfirmGeneric': 'Czy na pewno chcesz to usunąć?', + 'journey.notFound': 'Nie znaleziono dziennika podróży', + 'journey.photos': 'Zdjęcia', + 'journey.timelineEmpty': 'Brak przystanków', + 'journey.timelineEmptyHint': 'Dodaj zameldowanie lub napisz wpis w dzienniku, aby rozpocząć', + 'journey.status.draft': 'Szkic', + 'journey.status.active': 'Aktywny', + 'journey.status.completed': 'Zakończony', + 'journey.status.upcoming': 'Nadchodzący', + 'journey.checkin.add': 'Zamelduj się', + 'journey.checkin.namePlaceholder': 'Nazwa miejsca', + 'journey.checkin.notesPlaceholder': 'Notatki (opcjonalnie)', + 'journey.checkin.save': 'Zapisz', + 'journey.checkin.error': 'Nie udało się zapisać zameldowania', + 'journey.entry.add': 'Dziennik', + 'journey.entry.edit': 'Edytuj wpis', + 'journey.entry.titlePlaceholder': 'Tytuł (opcjonalnie)', + 'journey.entry.bodyPlaceholder': 'Co się dziś wydarzyło?', + 'journey.entry.save': 'Zapisz', + 'journey.entry.error': 'Nie udało się zapisać wpisu', + 'journey.photo.add': 'Zdjęcie', + 'journey.photo.uploadError': 'Przesyłanie nie powiodło się', + 'journey.share.share': 'Udostępnij', + 'journey.share.public': 'Publiczny', + 'journey.share.linkCopied': 'Publiczny link skopiowany', + 'journey.share.disabled': 'Udostępnianie publiczne wyłączone', + 'journey.editor.titlePlaceholder': 'Nadaj temu momentowi nazwę...', + 'journey.editor.bodyPlaceholder': 'Opowiedz historię tego dnia...', + 'journey.editor.placePlaceholder': 'Lokalizacja (opcjonalnie)', + 'journey.editor.tagsPlaceholder': 'Tagi: ukryty skarb, najlepszy posiłek, warto wrócić...', + 'journey.visibility.private': 'Prywatny', + 'journey.visibility.shared': 'Udostępniony', + 'journey.visibility.public': 'Publiczny', + 'journey.emptyState.title': 'Twoja historia zaczyna się tutaj', + 'journey.emptyState.subtitle': 'Zamelduj się w miejscu lub napisz swój pierwszy wpis w dzienniku', + 'journey.frontpage.subtitle': 'Zamień swoje podróże w historie, których nigdy nie zapomnisz', + 'journey.frontpage.createJourney': 'Utwórz dziennik podróży', + 'journey.frontpage.activeJourney': 'Aktywny dziennik podróży', + 'journey.frontpage.allJourneys': 'Wszystkie dzienniki podróży', + 'journey.frontpage.journeys': 'dzienniki podróży', + 'journey.frontpage.createNew': 'Utwórz nowy dziennik podróży', + 'journey.frontpage.createNewSub': 'Wybierz podróże, pisz historie, dziel się przygodami', + 'journey.frontpage.live': 'Na żywo', + 'journey.frontpage.synced': 'Zsynchronizowany', + 'journey.frontpage.continueWriting': 'Kontynuuj pisanie', + 'journey.frontpage.updated': 'Zaktualizowano {time}', + 'journey.frontpage.suggestionLabel': 'Podróż właśnie się zakończyła', + 'journey.frontpage.suggestionText': 'Zamień {title} w dziennik podróży', + 'journey.frontpage.dismiss': 'Odrzuć', + 'journey.frontpage.journeyName': 'Nazwa dziennika podróży', + 'journey.frontpage.namePlaceholder': 'np. Azja Południowo-Wschodnia 2026', + 'journey.frontpage.selectTrips': 'Wybierz podróże', + 'journey.frontpage.tripsSelected': 'podróży wybranych', + 'journey.frontpage.trips': 'podróże', + 'journey.frontpage.placesImported': 'miejsc zostanie zaimportowanych', + 'journey.frontpage.places': 'miejsca', + 'journey.detail.backToJourney': 'Powrót do dziennika podróży', + 'journey.detail.syncedWithTrips': 'Zsynchronizowany z podróżami', + 'journey.detail.addEntry': 'Dodaj wpis', + 'journey.detail.newEntry': 'Nowy wpis', + 'journey.detail.editEntry': 'Edytuj wpis', + 'journey.detail.noEntries': 'Brak wpisów', + 'journey.detail.noEntriesHint': 'Dodaj podróż, aby rozpocząć ze szkieletowymi wpisami', + 'journey.detail.noPhotos': 'Brak zdjęć', + 'journey.detail.noPhotosHint': 'Prześlij zdjęcia do wpisów lub przeglądaj bibliotekę Immich/Synology', + 'journey.detail.journeyStats': 'Statystyki podróży', + 'journey.detail.syncedTrips': 'Zsynchronizowane podróże', + 'journey.detail.noTripsLinked': 'Brak powiązanych podróży', + 'journey.detail.contributors': 'Współtwórcy', + 'journey.detail.readMore': 'Czytaj dalej', + 'journey.detail.prosCons': 'Zalety i wady', + 'journey.stats.days': 'Dni', + 'journey.stats.cities': 'Miasta', + 'journey.stats.entries': 'Wpisy', + 'journey.stats.photos': 'Zdjęcia', + 'journey.stats.places': 'Miejsca', + 'journey.verdict.lovedIt': 'Świetne', + 'journey.verdict.couldBeBetter': 'Mogłoby być lepiej', + 'journey.synced.places': 'miejsca', + 'journey.synced.synced': 'zsynchronizowane', + 'journey.editor.uploadPhotos': 'Prześlij zdjęcia', + 'journey.editor.fromGallery': 'Z galerii', + 'journey.editor.allPhotosAdded': 'Wszystkie zdjęcia już dodane', + 'journey.editor.writeStory': 'Napisz swoją historię...', + 'journey.editor.prosCons': 'Zalety i wady', + 'journey.editor.pros': 'Zalety', + 'journey.editor.cons': 'Wady', + 'journey.editor.proPlaceholder': 'Coś świetnego...', + 'journey.editor.conPlaceholder': 'Nie tak świetne...', + 'journey.editor.addAnother': 'Dodaj kolejny', + 'journey.editor.date': 'Data', + 'journey.editor.location': 'Lokalizacja', + 'journey.editor.searchLocation': 'Szukaj lokalizacji...', + 'journey.editor.mood': 'Nastrój', + 'journey.editor.weather': 'Pogoda', + 'journey.editor.photoFirst': '1.', + 'journey.editor.makeFirst': 'Ustaw jako 1.', + 'journey.mood.amazing': 'Niesamowity', + 'journey.mood.good': 'Dobry', + 'journey.mood.neutral': 'Neutralny', + 'journey.mood.rough': 'Ciężki', + 'journey.weather.sunny': 'Słonecznie', + 'journey.weather.partly': 'Częściowe zachmurzenie', + 'journey.weather.cloudy': 'Pochmurno', + 'journey.weather.rainy': 'Deszczowo', + 'journey.weather.stormy': 'Burzowo', + 'journey.weather.cold': 'Śnieżnie', + 'journey.trips.linkTrip': 'Powiąż podróż', + 'journey.trips.searchTrip': 'Szukaj podróży', + 'journey.trips.searchPlaceholder': 'Nazwa podróży lub cel...', + 'journey.trips.noTripsAvailable': 'Brak dostępnych podróży', + 'journey.trips.link': 'Powiąż', + 'journey.trips.tripLinked': 'Podróż powiązana', + 'journey.trips.linkFailed': 'Powiązanie podróży nie powiodło się', + 'journey.trips.addTrip': 'Dodaj podróż', + 'journey.trips.unlinkTrip': 'Odłącz podróż', + 'journey.trips.unlinkMessage': 'Odłączyć „{title}"? Wszystkie zsynchronizowane wpisy i zdjęcia z tej podróży zostaną trwale usunięte. Tej operacji nie można cofnąć.', + 'journey.trips.unlink': 'Odłącz', + 'journey.trips.tripUnlinked': 'Podróż odłączona', + 'journey.trips.unlinkFailed': 'Odłączenie podróży nie powiodło się', + 'journey.trips.noTripsLinkedSettings': 'Brak powiązanych podróży', + 'journey.contributors.invite': 'Zaproś współtwórcę', + 'journey.contributors.searchUser': 'Szukaj użytkownika', + 'journey.contributors.searchPlaceholder': 'Nazwa użytkownika lub e-mail...', + 'journey.contributors.noUsers': 'Nie znaleziono użytkowników', + 'journey.contributors.role': 'Rola', + 'journey.contributors.added': 'Współtwórca dodany', + 'journey.contributors.addFailed': 'Dodawanie współtwórcy nie powiodło się', + 'journey.share.publicShare': 'Udostępnianie publiczne', + 'journey.share.createLink': 'Utwórz link udostępniania', + 'journey.share.linkCreated': 'Link udostępniania utworzony', + 'journey.share.createFailed': 'Tworzenie linku nie powiodło się', + 'journey.share.copy': 'Kopiuj', + 'journey.share.copied': 'Skopiowano!', + 'journey.share.timeline': 'Oś czasu', + 'journey.share.gallery': 'Galeria', + 'journey.share.map': 'Mapa', + 'journey.share.removeLink': 'Usuń link udostępniania', + 'journey.share.linkDeleted': 'Link udostępniania usunięty', + 'journey.share.deleteFailed': 'Usunięcie nie powiodło się', + 'journey.share.updateFailed': 'Aktualizacja nie powiodła się', + 'journey.settings.title': 'Ustawienia dziennika podróży', + 'journey.settings.coverImage': 'Zdjęcie okładkowe', + 'journey.settings.changeCover': 'Zmień okładkę', + 'journey.settings.addCover': 'Dodaj zdjęcie okładkowe', + 'journey.settings.name': 'Nazwa', + 'journey.settings.subtitle': 'Podtytuł', + 'journey.settings.subtitlePlaceholder': 'np. Tajlandia, Wietnam i Kambodża', + 'journey.settings.delete': 'Usuń', + 'journey.settings.deleteJourney': 'Usuń dziennik podróży', + 'journey.settings.deleteMessage': 'Usunąć „{title}"? Wszystkie wpisy i zdjęcia zostaną utracone.', + 'journey.settings.saved': 'Ustawienia zapisane', + 'journey.settings.saveFailed': 'Zapisywanie nie powiodło się', + 'journey.settings.coverUpdated': 'Okładka zaktualizowana', + 'journey.settings.coverFailed': 'Przesyłanie nie powiodło się', + 'journey.public.notFound': 'Nie znaleziono', + 'journey.public.notFoundMessage': 'Ten dziennik podróży nie istnieje lub link wygasł.', + 'journey.public.readOnly': 'Tylko do odczytu · Publiczny dziennik podróży', + 'journey.public.tagline': 'Travel Resource & Exploration Kit', + 'journey.public.sharedVia': 'Udostępnione przez', + 'journey.public.madeWith': 'Stworzone z', + 'journey.pdf.journeyBook': 'Książka podróży', + 'journey.pdf.madeWith': 'Stworzone z TREK', + 'journey.pdf.day': 'Dzień', + 'journey.pdf.theEnd': 'Koniec', + 'journey.pdf.saveAsPdf': 'Zapisz jako PDF', + 'journey.pdf.pages': 'stron', + 'dashboard.greeting.morning': 'Dzień dobry,', + 'dashboard.greeting.afternoon': 'Dzień dobry,', + 'dashboard.greeting.evening': 'Dobry wieczór,', + 'dashboard.mobile.liveNow': 'Na żywo', + 'dashboard.mobile.tripProgress': 'Postęp podróży', + 'dashboard.mobile.daysLeft': 'Pozostało {count} dni', + 'dashboard.mobile.places': 'Miejsca', + 'dashboard.mobile.buddies': 'Towarzysze', + 'dashboard.mobile.newTrip': 'Nowa podróż', + 'dashboard.mobile.currency': 'Waluta', + 'dashboard.mobile.timezone': 'Strefa czasowa', + 'dashboard.mobile.upcomingTrips': 'Nadchodzące podróże', + 'dashboard.mobile.yourTrips': 'Twoje podróże', + 'dashboard.mobile.trips': 'podróże', + 'dashboard.mobile.starts': 'Początek', + 'dashboard.mobile.duration': 'Czas trwania', + 'dashboard.mobile.day': 'dzień', + 'dashboard.mobile.days': 'dni', + 'dashboard.mobile.ongoing': 'W trakcie', + 'dashboard.mobile.startsToday': 'Zaczyna się dziś', + 'dashboard.mobile.tomorrow': 'Jutro', + 'dashboard.mobile.inDays': 'Za {count} dni', + 'dashboard.mobile.inMonths': 'Za {count} miesięcy', + 'dashboard.mobile.completed': 'Zakończone', + 'dashboard.mobile.currencyConverter': 'Przelicznik walut', + 'nav.profile': 'Profil', + 'nav.bottomSettings': 'Ustawienia', + 'nav.bottomAdmin': 'Ustawienia administratora', + 'nav.bottomLogout': 'Wyloguj się', + 'nav.bottomAdminBadge': 'Administrator', + 'dayplan.mobile.addPlace': 'Dodaj miejsce', + 'dayplan.mobile.searchPlaces': 'Szukaj miejsc...', + 'dayplan.mobile.allAssigned': 'Wszystkie miejsca przypisane', + 'dayplan.mobile.noMatch': 'Brak wyników', + 'dayplan.mobile.createNew': 'Utwórz nowe miejsce', + 'admin.addons.catalog.journey.name': 'Dziennik podróży', + 'admin.addons.catalog.journey.description': 'Śledzenie podróży i dziennik z zameldowaniami, zdjęciami i codziennymi historiami', } export default pl diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 18001fc9..2ce65842 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -1686,6 +1686,239 @@ const ru: Record = { 'notif.generic.text': 'У вас новое уведомление', 'notif.dev.unknown_event.title': '[DEV] Неизвестное событие', 'notif.dev.unknown_event.text': 'Тип события "{event}" не зарегистрирован в EVENT_NOTIFICATION_CONFIG', + + // Journey, Dashboard, Nav, DayPlan + 'common.justNow': 'только что', + 'common.hoursAgo': '{count} ч назад', + 'common.daysAgo': '{count} д назад', + 'budget.linkedToReservation': 'Привязано к бронированию — измените название там', + 'packing.saveAsTemplate': 'Сохранить как шаблон', + 'packing.templateName': 'Название шаблона', + 'packing.templateSaved': 'Список вещей сохранён как шаблон', + 'memories.notConnectedMultipleHint': 'Подключите любого из этих фото-провайдеров: {provider_names} в Настройках, чтобы добавлять фото к этой поездке.', + 'memories.providerUrl': 'URL сервера', + 'memories.providerApiKey': 'API-ключ', + 'memories.providerUsername': 'Имя пользователя', + 'memories.providerPassword': 'Пароль', + 'memories.saveError': 'Не удалось сохранить настройки {provider_name}', + 'memories.selectAlbumMultiple': 'Выбрать альбом', + 'memories.selectPhotosMultiple': 'Выбрать фото', + 'journey.title': 'Путешествие', + 'journey.subtitle': 'Отслеживайте свои путешествия в реальном времени', + 'journey.new': 'Новое путешествие', + 'journey.create': 'Создать', + 'journey.titlePlaceholder': 'Куда вы едете?', + 'journey.empty': 'Пока нет путешествий', + 'journey.emptyHint': 'Начните документировать свою следующую поездку', + 'journey.deleted': 'Путешествие удалено', + 'journey.createError': 'Не удалось создать путешествие', + 'journey.deleteError': 'Не удалось удалить путешествие', + 'journey.deleteConfirmTitle': 'Удалить', + 'journey.deleteConfirmMessage': 'Удалить «{title}»? Это действие нельзя отменить.', + 'journey.deleteConfirmGeneric': 'Вы уверены, что хотите удалить это?', + 'journey.notFound': 'Путешествие не найдено', + 'journey.photos': 'Фото', + 'journey.timelineEmpty': 'Пока нет остановок', + 'journey.timelineEmptyHint': 'Добавьте отметку или напишите запись в дневник', + 'journey.status.draft': 'Черновик', + 'journey.status.active': 'Активно', + 'journey.status.completed': 'Завершено', + 'journey.status.upcoming': 'Предстоящее', + 'journey.checkin.add': 'Отметиться', + 'journey.checkin.namePlaceholder': 'Название места', + 'journey.checkin.notesPlaceholder': 'Заметки (необязательно)', + 'journey.checkin.save': 'Сохранить', + 'journey.checkin.error': 'Не удалось сохранить отметку', + 'journey.entry.add': 'Дневник', + 'journey.entry.edit': 'Редактировать запись', + 'journey.entry.titlePlaceholder': 'Заголовок (необязательно)', + 'journey.entry.bodyPlaceholder': 'Что произошло сегодня?', + 'journey.entry.save': 'Сохранить', + 'journey.entry.error': 'Не удалось сохранить запись', + 'journey.photo.add': 'Фото', + 'journey.photo.uploadError': 'Загрузка не удалась', + 'journey.share.share': 'Поделиться', + 'journey.share.public': 'Публичный', + 'journey.share.linkCopied': 'Публичная ссылка скопирована', + 'journey.share.disabled': 'Публичный доступ отключён', + 'journey.editor.titlePlaceholder': 'Дайте название этому моменту...', + 'journey.editor.bodyPlaceholder': 'Расскажите историю этого дня...', + 'journey.editor.placePlaceholder': 'Местоположение (необязательно)', + 'journey.editor.tagsPlaceholder': 'Теги: скрытая жемчужина, лучшая еда, стоит вернуться...', + 'journey.visibility.private': 'Приватный', + 'journey.visibility.shared': 'Общий', + 'journey.visibility.public': 'Публичный', + 'journey.emptyState.title': 'Ваша история начинается здесь', + 'journey.emptyState.subtitle': 'Отметьтесь в месте или напишите первую запись в дневник', + 'journey.frontpage.subtitle': 'Превращайте поездки в истории, которые вы никогда не забудете', + 'journey.frontpage.createJourney': 'Создать путешествие', + 'journey.frontpage.activeJourney': 'Активное путешествие', + 'journey.frontpage.allJourneys': 'Все путешествия', + 'journey.frontpage.journeys': 'путешествий', + 'journey.frontpage.createNew': 'Создать новое путешествие', + 'journey.frontpage.createNewSub': 'Выберите поездки, пишите истории, делитесь приключениями', + 'journey.frontpage.live': 'В эфире', + 'journey.frontpage.synced': 'Синхронизировано', + 'journey.frontpage.continueWriting': 'Продолжить писать', + 'journey.frontpage.updated': 'Обновлено {time}', + 'journey.frontpage.suggestionLabel': 'Поездка только что завершилась', + 'journey.frontpage.suggestionText': 'Превратите {title} в путешествие', + 'journey.frontpage.dismiss': 'Скрыть', + 'journey.frontpage.journeyName': 'Название путешествия', + 'journey.frontpage.namePlaceholder': 'напр. Юго-Восточная Азия 2026', + 'journey.frontpage.selectTrips': 'Выбрать поездки', + 'journey.frontpage.tripsSelected': 'поездок выбрано', + 'journey.frontpage.trips': 'поездок', + 'journey.frontpage.placesImported': 'мест будет импортировано', + 'journey.frontpage.places': 'мест', + 'journey.detail.backToJourney': 'Назад к путешествию', + 'journey.detail.syncedWithTrips': 'Синхронизировано с поездками', + 'journey.detail.addEntry': 'Добавить запись', + 'journey.detail.newEntry': 'Новая запись', + 'journey.detail.editEntry': 'Редактировать запись', + 'journey.detail.noEntries': 'Пока нет записей', + 'journey.detail.noEntriesHint': 'Добавьте поездку, чтобы начать с шаблонных записей', + 'journey.detail.noPhotos': 'Пока нет фото', + 'journey.detail.noPhotosHint': 'Загрузите фото в записи или просмотрите библиотеку Immich/Synology', + 'journey.detail.journeyStats': 'Статистика путешествия', + 'journey.detail.syncedTrips': 'Синхронизированные поездки', + 'journey.detail.noTripsLinked': 'Пока нет привязанных поездок', + 'journey.detail.contributors': 'Участники', + 'journey.detail.readMore': 'Читать далее', + 'journey.detail.prosCons': 'Плюсы и минусы', + 'journey.stats.days': 'Дней', + 'journey.stats.cities': 'Городов', + 'journey.stats.entries': 'Записей', + 'journey.stats.photos': 'Фото', + 'journey.stats.places': 'Мест', + 'journey.verdict.lovedIt': 'Понравилось', + 'journey.verdict.couldBeBetter': 'Могло быть лучше', + 'journey.synced.places': 'мест', + 'journey.synced.synced': 'синхронизировано', + 'journey.editor.uploadPhotos': 'Загрузить фото', + 'journey.editor.fromGallery': 'Из галереи', + 'journey.editor.allPhotosAdded': 'Все фото уже добавлены', + 'journey.editor.writeStory': 'Напишите свою историю...', + 'journey.editor.prosCons': 'Плюсы и минусы', + 'journey.editor.pros': 'Плюсы', + 'journey.editor.cons': 'Минусы', + 'journey.editor.proPlaceholder': 'Что-то отличное...', + 'journey.editor.conPlaceholder': 'Не очень хорошо...', + 'journey.editor.addAnother': 'Добавить ещё', + 'journey.editor.date': 'Дата', + 'journey.editor.location': 'Местоположение', + 'journey.editor.searchLocation': 'Поиск местоположения...', + 'journey.editor.mood': 'Настроение', + 'journey.editor.weather': 'Погода', + 'journey.editor.photoFirst': '1-е', + 'journey.editor.makeFirst': 'Сделать 1-м', + 'journey.mood.amazing': 'Потрясающе', + 'journey.mood.good': 'Хорошо', + 'journey.mood.neutral': 'Нейтрально', + 'journey.mood.rough': 'Тяжело', + 'journey.weather.sunny': 'Солнечно', + 'journey.weather.partly': 'Переменная облачность', + 'journey.weather.cloudy': 'Облачно', + 'journey.weather.rainy': 'Дождливо', + 'journey.weather.stormy': 'Гроза', + 'journey.weather.cold': 'Снежно', + 'journey.trips.linkTrip': 'Привязать поездку', + 'journey.trips.searchTrip': 'Поиск поездки', + 'journey.trips.searchPlaceholder': 'Название поездки или направление...', + 'journey.trips.noTripsAvailable': 'Нет доступных поездок', + 'journey.trips.link': 'Привязать', + 'journey.trips.tripLinked': 'Поездка привязана', + 'journey.trips.linkFailed': 'Не удалось привязать поездку', + 'journey.trips.addTrip': 'Добавить поездку', + 'journey.trips.unlinkTrip': 'Отвязать поездку', + 'journey.trips.unlinkMessage': 'Отвязать «{title}»? Все синхронизированные записи и фото из этой поездки будут безвозвратно удалены. Это действие нельзя отменить.', + 'journey.trips.unlink': 'Отвязать', + 'journey.trips.tripUnlinked': 'Поездка отвязана', + 'journey.trips.unlinkFailed': 'Не удалось отвязать поездку', + 'journey.trips.noTripsLinkedSettings': 'Нет привязанных поездок', + 'journey.contributors.invite': 'Пригласить участника', + 'journey.contributors.searchUser': 'Поиск пользователя', + 'journey.contributors.searchPlaceholder': 'Имя пользователя или email...', + 'journey.contributors.noUsers': 'Пользователи не найдены', + 'journey.contributors.role': 'Роль', + 'journey.contributors.added': 'Участник добавлен', + 'journey.contributors.addFailed': 'Не удалось добавить участника', + 'journey.share.publicShare': 'Публичный доступ', + 'journey.share.createLink': 'Создать ссылку для общего доступа', + 'journey.share.linkCreated': 'Ссылка создана', + 'journey.share.createFailed': 'Не удалось создать ссылку', + 'journey.share.copy': 'Копировать', + 'journey.share.copied': 'Скопировано!', + 'journey.share.timeline': 'Хронология', + 'journey.share.gallery': 'Галерея', + 'journey.share.map': 'Карта', + 'journey.share.removeLink': 'Удалить ссылку', + 'journey.share.linkDeleted': 'Ссылка удалена', + 'journey.share.deleteFailed': 'Не удалось удалить', + 'journey.share.updateFailed': 'Не удалось обновить', + 'journey.settings.title': 'Настройки путешествия', + 'journey.settings.coverImage': 'Обложка', + 'journey.settings.changeCover': 'Сменить обложку', + 'journey.settings.addCover': 'Добавить обложку', + 'journey.settings.name': 'Название', + 'journey.settings.subtitle': 'Подзаголовок', + 'journey.settings.subtitlePlaceholder': 'напр. Таиланд, Вьетнам и Камбоджа', + 'journey.settings.delete': 'Удалить', + 'journey.settings.deleteJourney': 'Удалить путешествие', + 'journey.settings.deleteMessage': 'Удалить «{title}»? Все записи и фото будут потеряны.', + 'journey.settings.saved': 'Настройки сохранены', + 'journey.settings.saveFailed': 'Не удалось сохранить', + 'journey.settings.coverUpdated': 'Обложка обновлена', + 'journey.settings.coverFailed': 'Загрузка не удалась', + 'journey.public.notFound': 'Не найдено', + 'journey.public.notFoundMessage': 'Это путешествие не существует или ссылка устарела.', + 'journey.public.readOnly': 'Только для чтения · Публичное путешествие', + 'journey.public.tagline': 'Инструмент планирования и исследования путешествий', + 'journey.public.sharedVia': 'Опубликовано через', + 'journey.public.madeWith': 'Сделано с помощью', + 'journey.pdf.journeyBook': 'Книга путешествия', + 'journey.pdf.madeWith': 'Сделано с помощью TREK', + 'journey.pdf.day': 'День', + 'journey.pdf.theEnd': 'Конец', + 'journey.pdf.saveAsPdf': 'Сохранить как PDF', + 'journey.pdf.pages': 'страниц', + 'dashboard.greeting.morning': 'Доброе утро,', + 'dashboard.greeting.afternoon': 'Добрый день,', + 'dashboard.greeting.evening': 'Добрый вечер,', + 'dashboard.mobile.liveNow': 'Сейчас в пути', + 'dashboard.mobile.tripProgress': 'Прогресс поездки', + 'dashboard.mobile.daysLeft': 'осталось {count} дн.', + 'dashboard.mobile.places': 'Места', + 'dashboard.mobile.buddies': 'Попутчики', + 'dashboard.mobile.newTrip': 'Новая поездка', + 'dashboard.mobile.currency': 'Валюта', + 'dashboard.mobile.timezone': 'Часовой пояс', + 'dashboard.mobile.upcomingTrips': 'Предстоящие поездки', + 'dashboard.mobile.yourTrips': 'Ваши поездки', + 'dashboard.mobile.trips': 'поездок', + 'dashboard.mobile.starts': 'Начало', + 'dashboard.mobile.duration': 'Продолжительность', + 'dashboard.mobile.day': 'день', + 'dashboard.mobile.days': 'дней', + 'dashboard.mobile.ongoing': 'В процессе', + 'dashboard.mobile.startsToday': 'Начинается сегодня', + 'dashboard.mobile.tomorrow': 'Завтра', + 'dashboard.mobile.inDays': 'Через {count} дн.', + 'dashboard.mobile.inMonths': 'Через {count} мес.', + 'dashboard.mobile.completed': 'Завершено', + 'dashboard.mobile.currencyConverter': 'Конвертер валют', + 'nav.profile': 'Профиль', + 'nav.bottomSettings': 'Настройки', + 'nav.bottomAdmin': 'Администрирование', + 'nav.bottomLogout': 'Выйти', + 'nav.bottomAdminBadge': 'Админ', + 'dayplan.mobile.addPlace': 'Добавить место', + 'dayplan.mobile.searchPlaces': 'Поиск мест...', + 'dayplan.mobile.allAssigned': 'Все места распределены', + 'dayplan.mobile.noMatch': 'Нет совпадений', + 'dayplan.mobile.createNew': 'Создать новое место', + 'admin.addons.catalog.journey.name': 'Путешествие', + 'admin.addons.catalog.journey.description': 'Отслеживание поездок и дневник путешествий с отметками, фото и ежедневными историями', } export default ru diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index d0af81d3..3926843f 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -1686,6 +1686,239 @@ const zh: Record = { 'notif.generic.text': '您有一条新通知', 'notif.dev.unknown_event.title': '[DEV] 未知事件', 'notif.dev.unknown_event.text': '事件类型 "{event}" 未在 EVENT_NOTIFICATION_CONFIG 中注册', + + // Journey, Dashboard, Nav, DayPlan + 'common.justNow': '刚刚', + 'common.hoursAgo': '{count}小时前', + 'common.daysAgo': '{count}天前', + 'budget.linkedToReservation': '已关联预订 — 请在预订中编辑名称', + 'packing.saveAsTemplate': '保存为模板', + 'packing.templateName': '模板名称', + 'packing.templateSaved': '打包清单已保存为模板', + 'memories.notConnectedMultipleHint': '在设置中连接以下任一照片服务:{provider_names},以便为此旅行添加照片。', + 'memories.providerUrl': '服务器地址', + 'memories.providerApiKey': 'API 密钥', + 'memories.providerUsername': '用户名', + 'memories.providerPassword': '密码', + 'memories.saveError': '无法保存 {provider_name} 设置', + 'memories.selectAlbumMultiple': '选择相册', + 'memories.selectPhotosMultiple': '选择照片', + 'journey.title': '旅程', + 'journey.subtitle': '实时记录你的旅行', + 'journey.new': '新建旅程', + 'journey.create': '创建', + 'journey.titlePlaceholder': '你要去哪里?', + 'journey.empty': '还没有旅程', + 'journey.emptyHint': '开始记录你的下一次旅行', + 'journey.deleted': '旅程已删除', + 'journey.createError': '无法创建旅程', + 'journey.deleteError': '无法删除旅程', + 'journey.deleteConfirmTitle': '删除', + 'journey.deleteConfirmMessage': '删除"{title}"?此操作无法撤销。', + 'journey.deleteConfirmGeneric': '确定要删除吗?', + 'journey.notFound': '未找到旅程', + 'journey.photos': '照片', + 'journey.timelineEmpty': '还没有行程', + 'journey.timelineEmptyHint': '添加一个签到或写一篇日志开始记录', + 'journey.status.draft': '草稿', + 'journey.status.active': '进行中', + 'journey.status.completed': '已完成', + 'journey.status.upcoming': '即将开始', + 'journey.checkin.add': '签到', + 'journey.checkin.namePlaceholder': '地点名称', + 'journey.checkin.notesPlaceholder': '备注(可选)', + 'journey.checkin.save': '保存', + 'journey.checkin.error': '无法保存签到', + 'journey.entry.add': '日志', + 'journey.entry.edit': '编辑条目', + 'journey.entry.titlePlaceholder': '标题(可选)', + 'journey.entry.bodyPlaceholder': '今天发生了什么?', + 'journey.entry.save': '保存', + 'journey.entry.error': '无法保存条目', + 'journey.photo.add': '照片', + 'journey.photo.uploadError': '上传失败', + 'journey.share.share': '分享', + 'journey.share.public': '公开', + 'journey.share.linkCopied': '公开链接已复制', + 'journey.share.disabled': '已关闭公开分享', + 'journey.editor.titlePlaceholder': '给这个瞬间起个名字...', + 'journey.editor.bodyPlaceholder': '讲述这一天的故事...', + 'journey.editor.placePlaceholder': '地点(可选)', + 'journey.editor.tagsPlaceholder': '标签:隐藏宝藏、最佳美食、值得再去...', + 'journey.visibility.private': '私密', + 'journey.visibility.shared': '共享', + 'journey.visibility.public': '公开', + 'journey.emptyState.title': '你的故事从这里开始', + 'journey.emptyState.subtitle': '在某个地方签到或写下你的第一篇日志', + 'journey.frontpage.subtitle': '将旅行变成永远不会忘记的故事', + 'journey.frontpage.createJourney': '创建旅程', + 'journey.frontpage.activeJourney': '进行中的旅程', + 'journey.frontpage.allJourneys': '所有旅程', + 'journey.frontpage.journeys': '个旅程', + 'journey.frontpage.createNew': '创建新旅程', + 'journey.frontpage.createNewSub': '选择旅行、写故事、分享你的冒险', + 'journey.frontpage.live': '实时', + 'journey.frontpage.synced': '已同步', + 'journey.frontpage.continueWriting': '继续写作', + 'journey.frontpage.updated': '更新于 {time}', + 'journey.frontpage.suggestionLabel': '旅行刚结束', + 'journey.frontpage.suggestionText': '将 {title} 变成一段旅程', + 'journey.frontpage.dismiss': '忽略', + 'journey.frontpage.journeyName': '旅程名称', + 'journey.frontpage.namePlaceholder': '例如 东南亚 2026', + 'journey.frontpage.selectTrips': '选择旅行', + 'journey.frontpage.tripsSelected': '个旅行已选择', + 'journey.frontpage.trips': '个旅行', + 'journey.frontpage.placesImported': '个地点将被导入', + 'journey.frontpage.places': '个地点', + 'journey.detail.backToJourney': '返回旅程', + 'journey.detail.syncedWithTrips': '已与旅行同步', + 'journey.detail.addEntry': '添加条目', + 'journey.detail.newEntry': '新建条目', + 'journey.detail.editEntry': '编辑条目', + 'journey.detail.noEntries': '还没有条目', + 'journey.detail.noEntriesHint': '添加一个旅行以生成骨架条目', + 'journey.detail.noPhotos': '还没有照片', + 'journey.detail.noPhotosHint': '上传照片到条目或浏览你的 Immich/Synology 相册', + 'journey.detail.journeyStats': '旅程统计', + 'journey.detail.syncedTrips': '已同步的旅行', + 'journey.detail.noTripsLinked': '尚未关联旅行', + 'journey.detail.contributors': '贡献者', + 'journey.detail.readMore': '阅读更多', + 'journey.detail.prosCons': '优缺点', + 'journey.stats.days': '天', + 'journey.stats.cities': '城市', + 'journey.stats.entries': '条目', + 'journey.stats.photos': '照片', + 'journey.stats.places': '地点', + 'journey.verdict.lovedIt': '非常喜欢', + 'journey.verdict.couldBeBetter': '有待改进', + 'journey.synced.places': '个地点', + 'journey.synced.synced': '已同步', + 'journey.editor.uploadPhotos': '上传照片', + 'journey.editor.fromGallery': '从相册选择', + 'journey.editor.allPhotosAdded': '所有照片已添加', + 'journey.editor.writeStory': '写下你的故事...', + 'journey.editor.prosCons': '优缺点', + 'journey.editor.pros': '优点', + 'journey.editor.cons': '缺点', + 'journey.editor.proPlaceholder': '好的方面...', + 'journey.editor.conPlaceholder': '不好的方面...', + 'journey.editor.addAnother': '再添加一个', + 'journey.editor.date': '日期', + 'journey.editor.location': '地点', + 'journey.editor.searchLocation': '搜索地点...', + 'journey.editor.mood': '心情', + 'journey.editor.weather': '天气', + 'journey.editor.photoFirst': '第1张', + 'journey.editor.makeFirst': '设为第1张', + 'journey.mood.amazing': '太棒了', + 'journey.mood.good': '不错', + 'journey.mood.neutral': '一般', + 'journey.mood.rough': '糟糕', + 'journey.weather.sunny': '晴天', + 'journey.weather.partly': '多云', + 'journey.weather.cloudy': '阴天', + 'journey.weather.rainy': '雨天', + 'journey.weather.stormy': '暴风雨', + 'journey.weather.cold': '雪天', + 'journey.trips.linkTrip': '关联旅行', + 'journey.trips.searchTrip': '搜索旅行', + 'journey.trips.searchPlaceholder': '旅行名称或目的地...', + 'journey.trips.noTripsAvailable': '没有可用的旅行', + 'journey.trips.link': '关联', + 'journey.trips.tripLinked': '旅行已关联', + 'journey.trips.linkFailed': '关联旅行失败', + 'journey.trips.addTrip': '添加旅行', + 'journey.trips.unlinkTrip': '取消关联旅行', + 'journey.trips.unlinkMessage': '取消关联"{title}"?此旅行中所有已同步的条目和照片将被永久删除。此操作无法撤销。', + 'journey.trips.unlink': '取消关联', + 'journey.trips.tripUnlinked': '旅行已取消关联', + 'journey.trips.unlinkFailed': '取消关联失败', + 'journey.trips.noTripsLinkedSettings': '未关联旅行', + 'journey.contributors.invite': '邀请贡献者', + 'journey.contributors.searchUser': '搜索用户', + 'journey.contributors.searchPlaceholder': '用户名或邮箱...', + 'journey.contributors.noUsers': '未找到用户', + 'journey.contributors.role': '角色', + 'journey.contributors.added': '贡献者已添加', + 'journey.contributors.addFailed': '添加贡献者失败', + 'journey.share.publicShare': '公开分享', + 'journey.share.createLink': '创建分享链接', + 'journey.share.linkCreated': '分享链接已创建', + 'journey.share.createFailed': '创建链接失败', + 'journey.share.copy': '复制', + 'journey.share.copied': '已复制!', + 'journey.share.timeline': '时间线', + 'journey.share.gallery': '图库', + 'journey.share.map': '地图', + 'journey.share.removeLink': '移除分享链接', + 'journey.share.linkDeleted': '分享链接已删除', + 'journey.share.deleteFailed': '删除失败', + 'journey.share.updateFailed': '更新失败', + 'journey.settings.title': '旅程设置', + 'journey.settings.coverImage': '封面图片', + 'journey.settings.changeCover': '更换封面', + 'journey.settings.addCover': '添加封面图片', + 'journey.settings.name': '名称', + 'journey.settings.subtitle': '副标题', + 'journey.settings.subtitlePlaceholder': '例如 泰国、越南和柬埔寨', + 'journey.settings.delete': '删除', + 'journey.settings.deleteJourney': '删除旅程', + 'journey.settings.deleteMessage': '删除"{title}"?所有条目和照片将丢失。', + 'journey.settings.saved': '设置已保存', + 'journey.settings.saveFailed': '保存失败', + 'journey.settings.coverUpdated': '封面已更新', + 'journey.settings.coverFailed': '上传失败', + 'journey.public.notFound': '未找到', + 'journey.public.notFoundMessage': '此旅程不存在或链接已过期。', + 'journey.public.readOnly': '只读 · 公开旅程', + 'journey.public.tagline': '旅行资源与探索工具包', + 'journey.public.sharedVia': '分享自', + 'journey.public.madeWith': '由', + 'journey.pdf.journeyBook': '旅程手册', + 'journey.pdf.madeWith': '由 TREK 制作', + 'journey.pdf.day': '第', + 'journey.pdf.theEnd': '终', + 'journey.pdf.saveAsPdf': '保存为 PDF', + 'journey.pdf.pages': '页', + 'dashboard.greeting.morning': '早上好,', + 'dashboard.greeting.afternoon': '下午好,', + 'dashboard.greeting.evening': '晚上好,', + 'dashboard.mobile.liveNow': '进行中', + 'dashboard.mobile.tripProgress': '旅行进度', + 'dashboard.mobile.daysLeft': '还剩 {count} 天', + 'dashboard.mobile.places': '地点', + 'dashboard.mobile.buddies': '旅伴', + 'dashboard.mobile.newTrip': '新建旅行', + 'dashboard.mobile.currency': '货币', + 'dashboard.mobile.timezone': '时区', + 'dashboard.mobile.upcomingTrips': '即将到来的旅行', + 'dashboard.mobile.yourTrips': '我的旅行', + 'dashboard.mobile.trips': '个旅行', + 'dashboard.mobile.starts': '出发', + 'dashboard.mobile.duration': '时长', + 'dashboard.mobile.day': '天', + 'dashboard.mobile.days': '天', + 'dashboard.mobile.ongoing': '进行中', + 'dashboard.mobile.startsToday': '今天出发', + 'dashboard.mobile.tomorrow': '明天', + 'dashboard.mobile.inDays': '{count} 天后', + 'dashboard.mobile.inMonths': '{count} 个月后', + 'dashboard.mobile.completed': '已完成', + 'dashboard.mobile.currencyConverter': '汇率转换', + 'nav.profile': '个人资料', + 'nav.bottomSettings': '设置', + 'nav.bottomAdmin': '管理设置', + 'nav.bottomLogout': '退出登录', + 'nav.bottomAdminBadge': '管理员', + 'dayplan.mobile.addPlace': '添加地点', + 'dayplan.mobile.searchPlaces': '搜索地点...', + 'dayplan.mobile.allAssigned': '所有地点已分配', + 'dayplan.mobile.noMatch': '无匹配', + 'dayplan.mobile.createNew': '创建新地点', + 'admin.addons.catalog.journey.name': '旅程', + 'admin.addons.catalog.journey.description': '旅行追踪与旅行日志,包含签到、照片和每日故事', } export default zh diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 86fa1418..9fd3a5ee 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -1541,6 +1541,378 @@ const zhTw: Record = { 'notifications.test.adminText': '{actor} 向所有管理員傳送了測試通知。', 'notifications.test.tripTitle': '{actor} 在您的行程中發帖', 'notifications.test.tripText': '行程"{trip}"的測試通知。', + + // Journey, Dashboard, Nav, DayPlan + 'common.justNow': '剛剛', + 'common.hoursAgo': '{count}小時前', + 'common.daysAgo': '{count}天前', + 'budget.linkedToReservation': '已關聯預訂 — 請在預訂中編輯名稱', + 'packing.saveAsTemplate': '儲存為範本', + 'packing.templateName': '範本名稱', + 'packing.templateSaved': '打包清單已儲存為範本', + 'memories.notConnectedMultipleHint': '在設定中連接以下任一照片服務:{provider_names},以便為此旅行新增照片。', + 'memories.providerUrl': '伺服器位址', + 'memories.providerApiKey': 'API 金鑰', + 'memories.providerUsername': '使用者名稱', + 'memories.providerPassword': '密碼', + 'memories.saveError': '無法儲存 {provider_name} 設定', + 'memories.selectAlbumMultiple': '選擇相簿', + 'memories.selectPhotosMultiple': '選擇照片', + 'journey.title': '旅程', + 'journey.subtitle': '即時記錄你的旅行', + 'journey.new': '新建旅程', + 'journey.create': '建立', + 'journey.titlePlaceholder': '你要去哪裡?', + 'journey.empty': '還沒有旅程', + 'journey.emptyHint': '開始記錄你的下一次旅行', + 'journey.deleted': '旅程已刪除', + 'journey.createError': '無法建立旅程', + 'journey.deleteError': '無法刪除旅程', + 'journey.deleteConfirmTitle': '刪除', + 'journey.deleteConfirmMessage': '刪除「{title}」?此操作無法復原。', + 'journey.deleteConfirmGeneric': '確定要刪除嗎?', + 'journey.notFound': '未找到旅程', + 'journey.photos': '照片', + 'journey.timelineEmpty': '還沒有行程', + 'journey.timelineEmptyHint': '新增一個打卡或寫一篇日誌開始記錄', + 'journey.status.draft': '草稿', + 'journey.status.active': '進行中', + 'journey.status.completed': '已完成', + 'journey.status.upcoming': '即將開始', + 'journey.checkin.add': '打卡', + 'journey.checkin.namePlaceholder': '地點名稱', + 'journey.checkin.notesPlaceholder': '備註(可選)', + 'journey.checkin.save': '儲存', + 'journey.checkin.error': '無法儲存打卡', + 'journey.entry.add': '日誌', + 'journey.entry.edit': '編輯條目', + 'journey.entry.titlePlaceholder': '標題(可選)', + 'journey.entry.bodyPlaceholder': '今天發生了什麼?', + 'journey.entry.save': '儲存', + 'journey.entry.error': '無法儲存條目', + 'journey.photo.add': '照片', + 'journey.photo.uploadError': '上傳失敗', + 'journey.share.share': '分享', + 'journey.share.public': '公開', + 'journey.share.linkCopied': '公開連結已複製', + 'journey.share.disabled': '已關閉公開分享', + 'journey.editor.titlePlaceholder': '給這個瞬間起個名字...', + 'journey.editor.bodyPlaceholder': '講述這一天的故事...', + 'journey.editor.placePlaceholder': '地點(可選)', + 'journey.editor.tagsPlaceholder': '標籤:隱藏寶藏、最佳美食、值得再訪...', + 'journey.visibility.private': '私密', + 'journey.visibility.shared': '共享', + 'journey.visibility.public': '公開', + 'journey.emptyState.title': '你的故事從這裡開始', + 'journey.emptyState.subtitle': '在某個地方打卡或寫下你的第一篇日誌', + 'journey.frontpage.subtitle': '將旅行變成永遠不會忘記的故事', + 'journey.frontpage.createJourney': '建立旅程', + 'journey.frontpage.activeJourney': '進行中的旅程', + 'journey.frontpage.allJourneys': '所有旅程', + 'journey.frontpage.journeys': '個旅程', + 'journey.frontpage.createNew': '建立新旅程', + 'journey.frontpage.createNewSub': '選擇旅行、寫故事、分享你的冒險', + 'journey.frontpage.live': '即時', + 'journey.frontpage.synced': '已同步', + 'journey.frontpage.continueWriting': '繼續撰寫', + 'journey.frontpage.updated': '更新於 {time}', + 'journey.frontpage.suggestionLabel': '旅行剛結束', + 'journey.frontpage.suggestionText': '將 {title} 變成一段旅程', + 'journey.frontpage.dismiss': '忽略', + 'journey.frontpage.journeyName': '旅程名稱', + 'journey.frontpage.namePlaceholder': '例如 東南亞 2026', + 'journey.frontpage.selectTrips': '選擇旅行', + 'journey.frontpage.tripsSelected': '個旅行已選擇', + 'journey.frontpage.trips': '個旅行', + 'journey.frontpage.placesImported': '個地點將被匯入', + 'journey.frontpage.places': '個地點', + 'journey.detail.backToJourney': '返回旅程', + 'journey.detail.syncedWithTrips': '已與旅行同步', + 'journey.detail.addEntry': '新增條目', + 'journey.detail.newEntry': '新建條目', + 'journey.detail.editEntry': '編輯條目', + 'journey.detail.noEntries': '還沒有條目', + 'journey.detail.noEntriesHint': '新增一個旅行以產生骨架條目', + 'journey.detail.noPhotos': '還沒有照片', + 'journey.detail.noPhotosHint': '上傳照片到條目或瀏覽你的 Immich/Synology 相簿', + 'journey.detail.journeyStats': '旅程統計', + 'journey.detail.syncedTrips': '已同步的旅行', + 'journey.detail.noTripsLinked': '尚未關聯旅行', + 'journey.detail.contributors': '貢獻者', + 'journey.detail.readMore': '閱讀更多', + 'journey.detail.prosCons': '優缺點', + 'journey.stats.days': '天', + 'journey.stats.cities': '城市', + 'journey.stats.entries': '條目', + 'journey.stats.photos': '照片', + 'journey.stats.places': '地點', + 'journey.verdict.lovedIt': '非常喜歡', + 'journey.verdict.couldBeBetter': '有待改進', + 'journey.synced.places': '個地點', + 'journey.synced.synced': '已同步', + 'journey.editor.uploadPhotos': '上傳照片', + 'journey.editor.fromGallery': '從相簿選擇', + 'journey.editor.allPhotosAdded': '所有照片已新增', + 'journey.editor.writeStory': '寫下你的故事...', + 'journey.editor.prosCons': '優缺點', + 'journey.editor.pros': '優點', + 'journey.editor.cons': '缺點', + 'journey.editor.proPlaceholder': '好的方面...', + 'journey.editor.conPlaceholder': '不好的方面...', + 'journey.editor.addAnother': '再新增一個', + 'journey.editor.date': '日期', + 'journey.editor.location': '地點', + 'journey.editor.searchLocation': '搜尋地點...', + 'journey.editor.mood': '心情', + 'journey.editor.weather': '天氣', + 'journey.editor.photoFirst': '第1張', + 'journey.editor.makeFirst': '設為第1張', + 'journey.mood.amazing': '太棒了', + 'journey.mood.good': '不錯', + 'journey.mood.neutral': '一般', + 'journey.mood.rough': '糟糕', + 'journey.weather.sunny': '晴天', + 'journey.weather.partly': '多雲', + 'journey.weather.cloudy': '陰天', + 'journey.weather.rainy': '雨天', + 'journey.weather.stormy': '暴風雨', + 'journey.weather.cold': '雪天', + 'journey.trips.linkTrip': '關聯旅行', + 'journey.trips.searchTrip': '搜尋旅行', + 'journey.trips.searchPlaceholder': '旅行名稱或目的地...', + 'journey.trips.noTripsAvailable': '沒有可用的旅行', + 'journey.trips.link': '關聯', + 'journey.trips.tripLinked': '旅行已關聯', + 'journey.trips.linkFailed': '關聯旅行失敗', + 'journey.trips.addTrip': '新增旅行', + 'journey.trips.unlinkTrip': '取消關聯旅行', + 'journey.trips.unlinkMessage': '取消關聯「{title}」?此旅行中所有已同步的條目和照片將被永久刪除。此操作無法復原。', + 'journey.trips.unlink': '取消關聯', + 'journey.trips.tripUnlinked': '旅行已取消關聯', + 'journey.trips.unlinkFailed': '取消關聯失敗', + 'journey.trips.noTripsLinkedSettings': '未關聯旅行', + 'journey.contributors.invite': '邀請貢獻者', + 'journey.contributors.searchUser': '搜尋使用者', + 'journey.contributors.searchPlaceholder': '使用者名稱或郵箱...', + 'journey.contributors.noUsers': '未找到使用者', + 'journey.contributors.role': '角色', + 'journey.contributors.added': '貢獻者已新增', + 'journey.contributors.addFailed': '新增貢獻者失敗', + 'journey.share.publicShare': '公開分享', + 'journey.share.createLink': '建立分享連結', + 'journey.share.linkCreated': '分享連結已建立', + 'journey.share.createFailed': '建立連結失敗', + 'journey.share.copy': '複製', + 'journey.share.copied': '已複製!', + 'journey.share.timeline': '時間線', + 'journey.share.gallery': '圖庫', + 'journey.share.map': '地圖', + 'journey.share.removeLink': '移除分享連結', + 'journey.share.linkDeleted': '分享連結已刪除', + 'journey.share.deleteFailed': '刪除失敗', + 'journey.share.updateFailed': '更新失敗', + 'journey.settings.title': '旅程設定', + 'journey.settings.coverImage': '封面圖片', + 'journey.settings.changeCover': '更換封面', + 'journey.settings.addCover': '新增封面圖片', + 'journey.settings.name': '名稱', + 'journey.settings.subtitle': '副標題', + 'journey.settings.subtitlePlaceholder': '例如 泰國、越南和柬埔寨', + 'journey.settings.delete': '刪除', + 'journey.settings.deleteJourney': '刪除旅程', + 'journey.settings.deleteMessage': '刪除「{title}」?所有條目和照片將遺失。', + 'journey.settings.saved': '設定已儲存', + 'journey.settings.saveFailed': '儲存失敗', + 'journey.settings.coverUpdated': '封面已更新', + 'journey.settings.coverFailed': '上傳失敗', + 'journey.public.notFound': '未找到', + 'journey.public.notFoundMessage': '此旅程不存在或連結已過期。', + 'journey.public.readOnly': '唯讀 · 公開旅程', + 'journey.public.tagline': '旅行資源與探索工具包', + 'journey.public.sharedVia': '分享自', + 'journey.public.madeWith': '由', + 'journey.pdf.journeyBook': '旅程手冊', + 'journey.pdf.madeWith': '由 TREK 製作', + 'journey.pdf.day': '第', + 'journey.pdf.theEnd': '終', + 'journey.pdf.saveAsPdf': '儲存為 PDF', + 'journey.pdf.pages': '頁', + 'dashboard.greeting.morning': '早安,', + 'dashboard.greeting.afternoon': '午安,', + 'dashboard.greeting.evening': '晚安,', + 'dashboard.mobile.liveNow': '進行中', + 'dashboard.mobile.tripProgress': '旅行進度', + 'dashboard.mobile.daysLeft': '還剩 {count} 天', + 'dashboard.mobile.places': '地點', + 'dashboard.mobile.buddies': '旅伴', + 'dashboard.mobile.newTrip': '新建旅行', + 'dashboard.mobile.currency': '貨幣', + 'dashboard.mobile.timezone': '時區', + 'dashboard.mobile.upcomingTrips': '即將到來的旅行', + 'dashboard.mobile.yourTrips': '我的旅行', + 'dashboard.mobile.trips': '個旅行', + 'dashboard.mobile.starts': '出發', + 'dashboard.mobile.duration': '時長', + 'dashboard.mobile.day': '天', + 'dashboard.mobile.days': '天', + 'dashboard.mobile.ongoing': '進行中', + 'dashboard.mobile.startsToday': '今天出發', + 'dashboard.mobile.tomorrow': '明天', + 'dashboard.mobile.inDays': '{count} 天後', + 'dashboard.mobile.inMonths': '{count} 個月後', + 'dashboard.mobile.completed': '已完成', + 'dashboard.mobile.currencyConverter': '匯率轉換', + 'nav.profile': '個人資料', + 'nav.bottomSettings': '設定', + 'nav.bottomAdmin': '管理設定', + 'nav.bottomLogout': '退出登入', + 'nav.bottomAdminBadge': '管理員', + 'dayplan.mobile.addPlace': '新增地點', + 'dayplan.mobile.searchPlaces': '搜尋地點...', + 'dayplan.mobile.allAssigned': '所有地點已分配', + 'dayplan.mobile.noMatch': '無匹配', + 'dayplan.mobile.createNew': '建立新地點', + 'admin.addons.catalog.journey.name': '旅程', + 'admin.addons.catalog.journey.description': '旅行追蹤與旅行日誌,包含打卡、照片和每日故事', + 'dashboard.dayCount': '天數', + 'dashboard.dayCountHint': '未設定旅行日期時規劃的天數。', + 'settings.tabs.display': '顯示', + 'settings.tabs.map': '地圖', + 'settings.tabs.notifications': '通知', + 'settings.tabs.integrations': '整合', + 'settings.tabs.account': '帳戶', + 'settings.tabs.about': '關於', + 'settings.notifyVersionAvailable': '有新版本可用', + 'settings.notificationPreferences.email': '電子郵件', + 'settings.notificationPreferences.webhook': 'Webhook', + 'settings.notificationPreferences.inapp': '應用內', + 'settings.notificationPreferences.noChannels': '尚未設定通知管道。請聯繫管理員設定電子郵件或 Webhook 通知。', + 'settings.webhookUrl.label': 'Webhook 網址', + 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', + 'settings.webhookUrl.hint': '輸入你的 Discord、Slack 或自訂 Webhook 網址以接收通知。', + 'settings.webhookUrl.save': '儲存', + 'settings.webhookUrl.saved': 'Webhook 網址已儲存', + 'settings.webhookUrl.test': '測試', + 'settings.webhookUrl.testSuccess': '測試 Webhook 傳送成功', + 'settings.webhookUrl.testFailed': '測試 Webhook 失敗', + 'admin.notifications.emailPanel.title': '電子郵件 (SMTP)', + 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.inappPanel.title': '應用內', + 'admin.notifications.inappPanel.hint': '應用內通知始終處於啟用狀態,無法全域停用。', + 'admin.notifications.adminWebhookPanel.title': '管理員 Webhook', + 'admin.notifications.adminWebhookPanel.hint': '此 Webhook 僅用於管理員通知(例如版本更新提醒)。它與每位使用者的 Webhook 分開,設定後將始終觸發。', + 'admin.notifications.adminWebhookPanel.saved': '管理員 Webhook 網址已儲存', + 'admin.notifications.adminWebhookPanel.testSuccess': '測試 Webhook 傳送成功', + 'admin.notifications.adminWebhookPanel.testFailed': '測試 Webhook 失敗', + 'admin.notifications.adminWebhookPanel.alwaysOnHint': '設定網址後管理員 Webhook 將始終觸發', + 'admin.notifications.adminNotificationsHint': '設定哪些管道傳送僅限管理員的通知(例如版本更新提醒)。', + 'settings.about.reportBug': '回報錯誤', + 'settings.about.reportBugHint': '發現問題?請告訴我們', + 'settings.about.featureRequest': '功能建議', + 'settings.about.featureRequestHint': '提出新功能建議', + 'settings.about.wikiHint': '文件與指南', + 'settings.about.description': 'TREK 是一個自架式旅行規劃工具,幫助你從第一個想法到最後一個回憶來組織旅行。日程規劃、預算、打包清單、照片等等——全部集中在一處,在你自己的伺服器上。', + 'settings.about.madeWith': '以', + 'settings.about.madeBy': '由 Maurice 和不斷壯大的開源社群製作。', + 'admin.tabs.notifications': '通知', + 'atlas.confirmUnmarkRegion': '將此地區從已造訪清單中移除?', + 'atlas.markRegionVisitedHint': '將此地區新增至已造訪清單', + 'trip.tabs.lists': '清單', + 'trip.tabs.listsShort': '清單', + 'reservations.price': '價格', + 'reservations.budgetCategory': '預算類別', + 'reservations.budgetCategoryPlaceholder': '例如 交通、住宿', + 'reservations.budgetCategoryAuto': '自動(依預訂類型)', + 'reservations.budgetHint': '儲存時將自動建立一筆預算項目。', + 'reservations.departureDate': '出發日期', + 'reservations.arrivalDate': '抵達日期', + 'reservations.departureTime': '出發時間', + 'reservations.arrivalTime': '抵達時間', + 'reservations.pickupDate': '取車日期', + 'reservations.returnDate': '還車日期', + 'reservations.pickupTime': '取車時間', + 'reservations.returnTime': '還車時間', + 'reservations.endDate': '結束日期', + 'reservations.meta.departureTimezone': '出發時區', + 'reservations.meta.arrivalTimezone': '抵達時區', + 'reservations.span.departure': '出發', + 'reservations.span.arrival': '抵達', + 'reservations.span.inTransit': '運輸中', + 'reservations.span.pickup': '取車', + 'reservations.span.return': '還車', + 'reservations.span.active': '使用中', + 'reservations.span.start': '開始', + 'reservations.span.end': '結束', + 'reservations.span.ongoing': '進行中', + 'reservations.validation.endBeforeStart': '結束日期/時間必須晚於開始日期/時間', + 'notifications.versionAvailable.title': '有可用更新', + 'notifications.versionAvailable.text': 'TREK {version} 現已推出。', + 'notifications.versionAvailable.button': '查看詳情', + 'todo.subtab.packing': '打包清單', + 'todo.subtab.todo': '待辦事項', + 'todo.completed': '已完成', + 'todo.filter.all': '全部', + 'todo.filter.open': '未完成', + 'todo.filter.done': '已完成', + 'todo.uncategorized': '未分類', + 'todo.namePlaceholder': '任務名稱', + 'todo.descriptionPlaceholder': '描述(可選)', + 'todo.unassigned': '未指派', + 'todo.noCategory': '無類別', + 'todo.hasDescription': '有描述', + 'todo.addItem': '新增任務...', + 'todo.newCategory': '類別名稱', + 'todo.addCategory': '新增類別', + 'todo.newItem': '新任務', + 'todo.empty': '還沒有任務。新增一個任務開始吧!', + 'todo.filter.my': '我的任務', + 'todo.filter.overdue': '已逾期', + 'todo.sidebar.tasks': '任務', + 'todo.sidebar.categories': '類別', + 'todo.detail.title': '任務', + 'todo.detail.description': '描述', + 'todo.detail.category': '類別', + 'todo.detail.dueDate': '截止日期', + 'todo.detail.assignedTo': '指派給', + 'todo.detail.delete': '刪除', + 'todo.detail.save': '儲存變更', + 'todo.sortByPrio': '優先順序', + 'todo.detail.priority': '優先順序', + 'todo.detail.noPriority': '無', + 'todo.detail.create': '建立任務', + 'notif.test.title': '[測試] 通知', + 'notif.test.simple.text': '這是一則簡單的測試通知。', + 'notif.test.boolean.text': '你是否接受這則測試通知?', + 'notif.test.navigate.text': '點擊下方前往儀表板。', + 'notif.trip_invite.title': '旅行邀請', + 'notif.trip_invite.text': '{actor} 邀請你加入 {trip}', + 'notif.booking_change.title': '預訂已更新', + 'notif.booking_change.text': '{actor} 更新了 {trip} 中的預訂', + 'notif.trip_reminder.title': '旅行提醒', + 'notif.trip_reminder.text': '你的旅行 {trip} 即將開始!', + 'notif.vacay_invite.title': 'Vacay Fusion 邀請', + 'notif.vacay_invite.text': '{actor} 邀請你合併假期計畫', + 'notif.photos_shared.title': '照片已分享', + 'notif.photos_shared.text': '{actor} 在 {trip} 中分享了 {count} 張照片', + 'notif.collab_message.title': '新訊息', + 'notif.collab_message.text': '{actor} 在 {trip} 中傳送了一則訊息', + 'notif.packing_tagged.title': '打包指派', + 'notif.packing_tagged.text': '{actor} 在 {trip} 中將 {category} 指派給你', + 'notif.version_available.title': '有新版本可用', + 'notif.version_available.text': 'TREK {version} 現已推出', + 'notif.action.view_trip': '查看旅行', + 'notif.action.view_collab': '查看訊息', + 'notif.action.view_packing': '查看打包', + 'notif.action.view_photos': '查看照片', + 'notif.action.view_vacay': '查看 Vacay', + 'notif.action.view_admin': '前往管理', + 'notif.action.view': '查看', + 'notif.action.accept': '接受', + 'notif.action.decline': '拒絕', + 'notif.generic.title': '通知', + 'notif.generic.text': '你有一則新通知', + 'notif.dev.unknown_event.title': '[DEV] 未知事件', + 'notif.dev.unknown_event.text': '事件類型「{event}」未在 EVENT_NOTIFICATION_CONFIG 中註冊', } export default zhTw \ No newline at end of file diff --git a/client/src/index.css b/client/src/index.css index cbba8d14..bb3d3f03 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -140,7 +140,7 @@ html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color /* ── Design tokens ─────────────────────────────── */ :root { --safe-top: env(safe-area-inset-top, 0px); - --nav-h: calc(56px + var(--safe-top)); + --nav-h: 0px; --font-system: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif; --sp-1: 4px; --sp-2: 8px; @@ -177,6 +177,24 @@ html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color --scrollbar-track: #f1f5f9; --scrollbar-thumb: #d1d5db; --scrollbar-hover: #9ca3af; + + /* Journey design tokens */ + --journal-bg: #FAFAFA; + --journal-card: #FFFFFF; + --journal-border: #E4E4E7; + --journal-accent: #6366F1; + --journal-text: #09090B; + --journal-muted: #71717A; + --journal-faint: #A1A1AA; + --mood-amazing: #E8654A; + --mood-good: #EF9F27; + --mood-neutral: #94928C; + --mood-tired: #6B9BD2; + --mood-rough: #9B8EC4; +} + +@media (min-width: 768px) { + :root { --nav-h: calc(56px + env(safe-area-inset-top, 0px)); } } .dark { @@ -202,6 +220,20 @@ html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color --scrollbar-track: #131316; --scrollbar-thumb: #3f3f46; --scrollbar-hover: #52525b; + + /* Journey design tokens (dark) */ + --journal-bg: #09090B; + --journal-card: #18181B; + --journal-border: #27272A; + --journal-accent: #818CF8; + --journal-text: #FAFAFA; + --journal-muted: #A1A1AA; + --journal-faint: #52525B; + --mood-amazing: #f28a6e; + --mood-good: #f5b84d; + --mood-neutral: #9a9a94; + --mood-tired: #6db3f0; + --mood-rough: #a9a3f0; } body { @@ -267,22 +299,23 @@ body { /* ── iOS-style map tooltip ─────────────────────── */ .leaflet-tooltip.map-tooltip { - background: var(--tooltip-bg); + background: rgba(9, 9, 11, 0.85); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); border: none; - border-radius: var(--radius-md); - box-shadow: var(--shadow-elevated); - padding: 6px 10px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + padding: 5px 10px; font-family: var(--font-system); + font-size: 11px; + font-weight: 500; pointer-events: none; - color: var(--text-primary); + color: #fff; } -.leaflet-tooltip.map-tooltip::before { - border-right-color: var(--tooltip-bg); -} -.leaflet-tooltip-left.map-tooltip::before { - border-left-color: var(--tooltip-bg); +.leaflet-tooltip.map-tooltip::before, +.leaflet-tooltip-left.map-tooltip::before, +.leaflet-tooltip-top.map-tooltip::before { + display: none; } /* Scrollbalken */ @@ -416,6 +449,11 @@ img[alt="TREK"] { } /* Toast-Animationen */ +@keyframes slideUp { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + @keyframes slide-in-right { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx index 2365c8cf..57664ff0 100644 --- a/client/src/pages/AtlasPage.tsx +++ b/client/src/pages/AtlasPage.tsx @@ -756,7 +756,7 @@ export default function AtlasPage(): React.ReactElement { return (
    -
    +
    {/* Map */}
    @@ -773,7 +773,7 @@ export default function AtlasPage(): React.ReactElement { }} />
    {/* Mobile: Bottom bar */} -
    +
    {/* Countries highlighted */} diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index a3863791..86fff2e2 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -15,7 +15,7 @@ import { useToast } from '../components/shared/Toast' import { Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp, Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft, Users, - LayoutGrid, List, Copy, + LayoutGrid, List, Copy, Bell, } from 'lucide-react' import { useCanDo } from '../store/permissionsStore' @@ -151,180 +151,312 @@ interface TripCardProps { dark?: boolean } -function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale, dark }: TripCardProps): React.ReactElement { +function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: TripCardProps): React.ReactElement { const status = getTripStatus(trip) + const isLive = status === 'ongoing' + const today = new Date().toISOString().split('T')[0] + const startDate = trip.start_date || today + const endDate = trip.end_date || today + const totalDays = Math.max(1, Math.ceil((new Date(endDate).getTime() - new Date(startDate).getTime()) / 86400000) + 1) + const currentDay = Math.min(totalDays, Math.ceil((new Date(today).getTime() - new Date(startDate).getTime()) / 86400000) + 1) + const daysLeft = Math.max(0, totalDays - currentDay) + const progress = Math.round((currentDay / totalDays) * 100) - const coverBg = trip.cover_image - ? `url(${trip.cover_image}) center/cover no-repeat` - : tripGradient(trip.id) - - return ( - onClick(trip)}> - {/* Cover / Background */} -
    -
    - - {/* Badges top-left */} -
    - {status && ( - - {status === 'ongoing' && ( - - )} - {status === 'ongoing' ? t('dashboard.status.ongoing') - : status === 'today' ? t('dashboard.status.today') - : status === 'tomorrow' ? t('dashboard.status.tomorrow') - : status === 'future' ? t('dashboard.status.daysLeft', { count: daysUntil(trip.start_date) }) - : t('dashboard.status.past')} - - )} -
    - - {/* Top-right actions */} - {(onEdit || onCopy || onArchive || onDelete) && ( -
    e.stopPropagation()}> - {onEdit && onEdit(trip)} title={t('common.edit')}>} - {onCopy && onCopy(trip)} title={t('dashboard.copyTrip')}>} - {onArchive && onArchive(trip.id)} title={t('dashboard.archive')}>} - {onDelete && onDelete(trip)} title={t('common.delete')} danger>} -
    - )} - - {/* Bottom content */} -
    -
    - {trip.is_owner ? t('dashboard.nextTrip') : t('dashboard.sharedBy', { name: trip.owner_username })} -
    -

    - {trip.title} -

    - {trip.description && ( -

    - {trip.description} -

    - )} -
    - {trip.start_date && ( -
    - - {formatDateShort(trip.start_date, locale)} - {trip.end_date && <> — {formatDateShort(trip.end_date, locale)}} -
    - )} -
    - {trip.day_count || 0} {t('dashboard.days')} -
    -
    - {trip.place_count || 0} {t('dashboard.places')} -
    -
    - {trip.shared_count+1 || 0} {t('dashboard.members')} -
    -
    -
    -
    - - ) -} - -// ── Regular Trip Card ──────────────────────────────────────────────────────── -function TripCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: Omit): React.ReactElement { - const status = getTripStatus(trip) - const [hovered, setHovered] = useState(false) - - const coverBg = trip.cover_image - ? `url(${trip.cover_image}) center/cover no-repeat` - : tripGradient(trip.id) + const badgeText = isLive ? t('dashboard.mobile.liveNow') + : status === 'today' ? t('dashboard.mobile.startsToday') + : status === 'tomorrow' ? t('dashboard.mobile.tomorrow') + : status === 'future' ? t('dashboard.status.daysLeft', { count: daysUntil(trip.start_date) }) + : status === 'past' ? t('dashboard.mobile.completed') + : null return (
    setHovered(true)} - onMouseLeave={() => setHovered(false)} onClick={() => onClick(trip)} - style={{ - background: hovered ? 'var(--bg-tertiary)' : 'var(--bg-card)', borderRadius: 16, overflow: 'hidden', cursor: 'pointer', - border: `1px solid ${hovered ? 'var(--text-faint)' : 'var(--border-primary)'}`, transition: 'all 0.18s', - boxShadow: hovered ? '0 8px 28px rgba(0,0,0,0.15)' : '0 1px 4px rgba(0,0,0,0.04)', - transform: hovered ? 'translateY(-2px)' : 'none', - }} + className="group relative rounded-3xl overflow-hidden cursor-pointer mb-8" + style={{ minHeight: 340, boxShadow: '0 8px 40px rgba(0,0,0,0.13)' }} > - {/* Image area */} -
    - {trip.cover_image &&
    } - - {/* Status badge */} - {status && ( -
    - - {status === 'ongoing' && ( - - )} - {status === 'ongoing' ? t('dashboard.status.ongoing') - : status === 'today' ? t('dashboard.status.today') - : status === 'tomorrow' ? t('dashboard.status.tomorrow') - : status === 'future' ? t('dashboard.status.daysLeft', { count: daysUntil(trip.start_date) }) - : t('dashboard.status.past')} - -
    + {/* Background */} +
    + {trip.cover_image && ( + <> + +
    + )}
    +
    {/* Content */} -
    -
    - - {trip.title} - +
    + {/* Top: badge + actions */} +
    + {badgeText ? ( + + {isLive ? ( + + ) : ( + + )} + {badgeText} + + ) : } +
    e.stopPropagation()}> + {onEdit && } + {onCopy && } + {onArchive && } + {onDelete && } +
    +
    + + {/* Title area — pushed to bottom */} +
    {!trip.is_owner && ( - - {t('dashboard.shared')} + + {t('dashboard.sharedBy', { name: trip.owner_username })} )} -
    - {trip.description && ( -

    - {trip.description} +

    {trip.title}

    +

    + {formatDateShort(trip.start_date, locale)} — {formatDateShort(trip.end_date, locale)} + {isLive && <> · {t('journey.pdf.day')} {currentDay} / {totalDays}}

    - )} +
    - {(trip.start_date || trip.end_date) && ( -
    - - {trip.start_date && trip.end_date - ? `${formatDateShort(trip.start_date, locale)} — ${formatDateShort(trip.end_date, locale)}` - : formatDate(trip.start_date || trip.end_date, locale)} + {/* Progress bar — only for live trips */} + {isLive && ( +
    +
    + {t('dashboard.mobile.tripProgress')} + {t('dashboard.mobile.daysLeft', { count: daysLeft })} +
    +
    +
    + +
    +
    )} -
    - - - + {/* Stats */} +
    + {trip.start_date && !isLive && ( +
    +

    {formatDateShort(trip.start_date, locale)}

    +

    {t('dashboard.mobile.starts')}

    +
    + )} + {isLive && ( +
    +

    {totalDays}

    +

    {t('dashboard.mobile.duration')}

    +
    + )} +
    +

    {trip.place_count || 0}

    +

    {t('dashboard.mobile.places')}

    +
    +
    +

    {trip.shared_count || 0}

    +

    {t('dashboard.mobile.buddies')}

    +
    + {!isLive && ( +
    +

    {trip.day_count || totalDays}

    +

    {t('dashboard.mobile.days')}

    +
    + )} +
    +
    +
    + ) +} + +// ── Mobile Trip Card (upcoming style) ──────────────────────────────────────── +function MobileTripCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: Omit): React.ReactElement { + const status = getTripStatus(trip) + const until = daysUntil(trip.start_date) + const duration = trip.start_date && trip.end_date + ? Math.ceil((new Date(trip.end_date).getTime() - new Date(trip.start_date).getTime()) / 86400000) + 1 + : trip.day_count || null + + const badgeText = status === 'ongoing' ? t('dashboard.mobile.ongoing') + : status === 'today' ? t('dashboard.mobile.startsToday') + : status === 'tomorrow' ? t('dashboard.mobile.tomorrow') + : until && until > 0 ? (until < 30 ? t('dashboard.mobile.inDays', { count: until }) : until < 365 ? t('dashboard.mobile.inMonths', { count: Math.round(until / 30) }) : `In ${Math.round(until / 365)}y`) + : status === 'past' ? t('dashboard.mobile.completed') + : null + + return ( +
    onClick?.(trip)} + className="rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md" + style={{ background: 'var(--bg-card)' }} + > + {/* Cover */} +
    + {trip.cover_image && ( + + )} +
    + + {/* Action buttons top-right */} +
    + {onEdit && } + {onCopy && } + {onArchive && } + {onDelete && }
    - {(onEdit || onCopy || onArchive || onDelete) && ( -
    e.stopPropagation()}> - {onEdit && onEdit(trip)} icon={} label={t('common.edit')} />} - {onCopy && onCopy(trip)} icon={} label={t('dashboard.copyTrip')} />} - {onArchive && onArchive(trip.id)} icon={} label={t('dashboard.archive')} />} - {onDelete && onDelete(trip)} icon={} label={t('common.delete')} danger />} -
    + {/* Countdown badge */} + {badgeText && ( +
    + + {status === 'ongoing' ? ( + + ) : ( + + )} + {badgeText} + +
    )} + + {/* Title on cover */} +
    +

    {trip.title}

    + {trip.description && ( +

    {trip.description}

    + )} +
    +
    + + {/* Bottom stats */} +
    +
    + {trip.start_date && ( +
    + {formatDateShort(trip.start_date, locale)} + {t('dashboard.mobile.starts')} +
    + )} + {duration && ( +
    + {duration} {duration === 1 ? t('dashboard.mobile.day') : t('dashboard.mobile.days')} + {t('dashboard.mobile.duration')} +
    + )} +
    + {trip.place_count || 0} + {t('dashboard.mobile.places')} +
    + {(trip.shared_count || 0) > 0 && ( +
    + {trip.shared_count} + {t('dashboard.mobile.buddies')} +
    + )} +
    +
    +
    + ) +} + +// ── Regular Trip Card (matches mobile card design) ────────────────────────── +function TripCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: Omit): React.ReactElement { + const status = getTripStatus(trip) + const until = daysUntil(trip.start_date) + const duration = trip.start_date && trip.end_date + ? Math.ceil((new Date(trip.end_date).getTime() - new Date(trip.start_date).getTime()) / 86400000) + 1 + : trip.day_count || null + + const badgeText = status === 'ongoing' ? t('dashboard.mobile.ongoing') + : status === 'today' ? t('dashboard.mobile.startsToday') + : status === 'tomorrow' ? t('dashboard.mobile.tomorrow') + : until && until > 0 ? (until < 30 ? t('dashboard.mobile.inDays', { count: until }) : until < 365 ? t('dashboard.mobile.inMonths', { count: Math.round(until / 30) }) : `In ${Math.round(until / 365)}y`) + : status === 'past' ? t('dashboard.mobile.completed') + : null + + return ( +
    onClick(trip)} + className="group rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-lg hover:border-zinc-300 dark:hover:border-zinc-600" + style={{ background: 'var(--bg-card)' }} + > + {/* Cover */} +
    + {trip.cover_image && ( + + )} +
    + + {/* Action buttons top-right — visible on hover */} +
    + {onEdit && } + {onCopy && } + {onArchive && } + {onDelete && } +
    + + {/* Status badge top-left */} + {badgeText && ( +
    + + {status === 'ongoing' ? ( + + ) : ( + + )} + {badgeText} + +
    + )} + + {/* Shared badge */} + {!trip.is_owner && ( +
    + + {t('dashboard.shared')} + +
    + )} + + {/* Title on cover */} +
    +

    {trip.title}

    + {trip.description && ( +

    {trip.description}

    + )} +
    +
    + + {/* Bottom stats */} +
    +
    + {trip.start_date && ( +
    + {formatDateShort(trip.start_date, locale)} + {t('dashboard.mobile.starts')} +
    + )} + {duration && ( +
    + {duration} {duration === 1 ? t('dashboard.mobile.day') : t('dashboard.mobile.days')} + {t('dashboard.mobile.duration')} +
    + )} +
    + {trip.place_count || 0} + {t('dashboard.mobile.places')} +
    + {(trip.shared_count || 0) > 0 && ( +
    + {trip.shared_count} + {t('dashboard.mobile.buddies')} +
    + )} +
    ) @@ -415,7 +547,7 @@ function TripListItem({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, l {trip.place_count || 0}
    - {trip.shared_count+1 || 0} + {trip.shared_count || 0}
    @@ -553,7 +685,7 @@ export default function DashboardPage(): React.ReactElement { const [showForm, setShowForm] = useState(false) const [editingTrip, setEditingTrip] = useState(null) const [showArchived, setShowArchived] = useState(false) - const [showWidgetSettings, setShowWidgetSettings] = useState(false) + const [showWidgetSettings, setShowWidgetSettings] = useState(false) const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid') const [deleteTrip, setDeleteTrip] = useState(null) @@ -568,7 +700,7 @@ export default function DashboardPage(): React.ReactElement { const navigate = useNavigate() const toast = useToast() const { t, locale } = useTranslation() - const { demoMode } = useAuthStore() + const { demoMode, user } = useAuthStore() const { settings, updateSetting } = useSettingsStore() const can = useCanDo() const dm = settings.dark_mode @@ -578,7 +710,7 @@ export default function DashboardPage(): React.ReactElement { const showSidebar = showCurrency || showTimezone useEffect(() => { - if (showWidgetSettings === 'mobile') { + if (showWidgetSettings === 'mobile' || showWidgetSettings === 'mobile-currency' || showWidgetSettings === 'mobile-timezone') { document.body.style.overflow = 'hidden' } else { document.body.style.overflow = '' @@ -689,10 +821,183 @@ export default function DashboardPage(): React.ReactElement { {demoMode && }
    -
    +
    - {/* Header */} -
    + {/* Mobile greeting header */} +
    +
    +

    {new Date().getHours() < 12 ? t('dashboard.greeting.morning') : new Date().getHours() < 18 ? t('dashboard.greeting.afternoon') : t('dashboard.greeting.evening')}

    +

    {user?.username || t('nav.profile')}

    +
    +
    + + +
    +
    + + {/* Mobile: Live Trip Hero */} + {(() => { + const liveTrip = trips.find(t => getTripStatus(t) === 'ongoing') + if (!liveTrip) return null + const today = new Date().toISOString().split('T')[0] + const startDate = liveTrip.start_date || today + const endDate = liveTrip.end_date || today + const totalDays = Math.max(1, Math.ceil((new Date(endDate).getTime() - new Date(startDate).getTime()) / 86400000) + 1) + const currentDay = Math.min(totalDays, Math.ceil((new Date(today).getTime() - new Date(startDate).getTime()) / 86400000) + 1) + const daysLeft = Math.max(0, totalDays - currentDay) + const progress = Math.round((currentDay / totalDays) * 100) + + return ( +
    +
    navigate(`/trips/${liveTrip.id}`)} + className="relative rounded-3xl overflow-hidden cursor-pointer" + style={{ minHeight: 340 }} + > + {/* Background */} +
    + {liveTrip.cover_image && ( + <> + +
    + + )} +
    +
    + + {/* Content */} +
    + {/* Top badges */} +
    + + + {t("dashboard.mobile.liveNow")} + +
    + + + + +
    +
    + + {/* Title area */} +
    +

    {liveTrip.title}

    +

    + {formatDateShort(liveTrip.start_date)} — {formatDateShort(liveTrip.end_date)} · {t('journey.pdf.day')} {currentDay} / {totalDays} +

    +
    + + {/* Progress */} +
    +
    + {t('dashboard.mobile.tripProgress')} + {daysLeft} days left +
    +
    +
    + +
    +
    +
    + + {/* Stats */} +
    +
    +

    {liveTrip.place_count || 0}

    +

    Places

    +
    +
    +

    {liveTrip.shared_count || 0}

    +

    Buddies

    +
    +
    +
    +
    +
    + ) + })()} + + {/* Mobile: Quick Actions */} +
    + {can('trip_create') && ( + + )} + {showCurrency && ( + + )} + {showTimezone && ( + + )} +
    + + {/* Desktop header */} +

    {t('dashboard.title')}

    @@ -774,17 +1079,7 @@ export default function DashboardPage(): React.ReactElement {

    )} - {/* Mobile widgets button */} - {showSidebar && ( - - )} + {/* Mobile widgets button — replaced by Quick Actions */}
    {/* Main content */} @@ -819,9 +1114,9 @@ export default function DashboardPage(): React.ReactElement {
    )} - {/* Spotlight (grid mode only) */} + {/* Spotlight (grid mode, desktop only — mobile has Live Hero) */} {!isLoading && spotlight && viewMode === 'grid' && ( - { setEditingTrip(tr); setShowForm(true) } : undefined} @@ -829,13 +1124,37 @@ export default function DashboardPage(): React.ReactElement { onDelete={can('trip_delete', spotlight) ? handleDelete : undefined} onArchive={can('trip_archive', spotlight) ? handleArchive : undefined} onClick={tr => navigate(`/trips/${tr.id}`)} - /> + />
    )} - {/* Trips — grid or list */} - {!isLoading && (viewMode === 'grid' ? rest : trips).length > 0 && ( + {/* Trips — mobile cards */} + {!isLoading && rest.length > 0 && ( +
    +
    + + {rest.some(t => getTripStatus(t) === 'future' || getTripStatus(t) === 'tomorrow') ? t('dashboard.mobile.upcomingTrips') : t('dashboard.mobile.yourTrips')} + + {rest.length} {t('dashboard.mobile.trips')} +
    + {rest.map(trip => ( + { setEditingTrip(tr); setShowForm(true) } : undefined} + onCopy={can('trip_create') ? handleCopy : undefined} + onDelete={can('trip_delete', trip) ? handleDelete : undefined} + onArchive={can('trip_archive', trip) ? handleArchive : undefined} + onClick={tr => navigate(`/trips/${tr.id}`)} + /> + ))} +
    + )} + + {/* Trips — desktop grid or list */} + {!isLoading && (viewMode === 'grid' ? rest : rest).length > 0 && ( viewMode === 'grid' ? ( -
    +
    {rest.map(trip => ( ) : ( -
    - {trips.map(trip => ( +
    + {rest.map(trip => ( {/* Mobile widgets bottom sheet */} - {showWidgetSettings === 'mobile' && ( + {(showWidgetSettings === 'mobile' || showWidgetSettings === 'mobile-currency' || showWidgetSettings === 'mobile-timezone') && (
    setShowWidgetSettings(false)}> -
    e.stopPropagation()}> -
    - Widgets +
    +
    +
    +
    + + {showWidgetSettings === 'mobile-currency' ? t('dashboard.mobile.currencyConverter') : showWidgetSettings === 'mobile-timezone' ? t('dashboard.mobile.timezone') : t('common.settings')} +
    -
    - {showCurrency && } - {showTimezone && } +
    + {(showWidgetSettings === 'mobile' || showWidgetSettings === 'mobile-currency') && showCurrency && } + {(showWidgetSettings === 'mobile' || showWidgetSettings === 'mobile-timezone') && showTimezone && }
    diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx new file mode 100644 index 00000000..5e2e6ac9 --- /dev/null +++ b/client/src/pages/JourneyDetailPage.tsx @@ -0,0 +1,2748 @@ +import { useEffect, useState, useRef, useCallback, useMemo } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { useJourneyStore } from '../store/journeyStore' +import { useAuthStore } from '../store/authStore' +import { useTranslation } from '../i18n' +import { journeyApi, authApi, addonsApi, mapsApi } from '../api/client' +import { addListener, removeListener } from '../api/websocket' +import Navbar from '../components/Layout/Navbar' +import JourneyMap from '../components/Journey/JourneyMap' +import type { JourneyMapHandle } from '../components/Journey/JourneyMap' +import JournalBody from '../components/Journey/JournalBody' +import MarkdownToolbar from '../components/Journey/MarkdownToolbar' +import PhotoLightbox from '../components/Journey/PhotoLightbox' +import { useToast } from '../components/shared/Toast' +import ConfirmDialog from '../components/shared/ConfirmDialog' +import { + ArrowLeft, RefreshCw, MoreHorizontal, Share2, Download, List, Grid, MapPin, Link, Copy, + Clock, Package, Image, ChevronRight, + UserPlus, Plus, Minus, Calendar, Camera, BookOpen, X, Check, ImagePlus, Trash2, Pencil, + Laugh, Smile, Meh, Annoyed, Frown, + Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, +} from 'lucide-react' +import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore' + +const GRADIENTS = [ + 'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)', + 'linear-gradient(135deg, #1E293B 0%, #7C3AED 50%, #F59E0B 100%)', + 'linear-gradient(135deg, #134E5E 0%, #71B280 100%)', + 'linear-gradient(135deg, #2D1B69 0%, #11998E 100%)', + 'linear-gradient(135deg, #4B134F 0%, #C94B4B 100%)', + 'linear-gradient(135deg, #373B44 0%, #4286F4 100%)', +] + +function pickGradient(id: number): string { + return GRADIENTS[id % GRADIENTS.length] +} + +const MOOD_CONFIG: Record = { + amazing: { bg: '#FDF2F8', text: '#BE185D', icon: Laugh, label: 'journey.mood.amazing' }, + good: { bg: '#FFFBEB', text: '#B45309', icon: Smile, label: 'journey.mood.good' }, + neutral: { bg: '#F4F4F5', text: '#3F3F46', icon: Meh, label: 'journey.mood.neutral' }, + rough: { bg: '#F5F3FF', text: '#6D28D9', icon: Frown, label: 'journey.mood.rough' }, +} + +const WEATHER_CONFIG: Record = { + sunny: { icon: Sun, label: 'journey.weather.sunny' }, + partly: { icon: CloudSun, label: 'journey.weather.partly' }, + cloudy: { icon: Cloud, label: 'journey.weather.cloudy' }, + rainy: { icon: CloudRain, label: 'journey.weather.rainy' }, + stormy: { icon: CloudLightning, label: 'journey.weather.stormy' }, + cold: { icon: Snowflake, label: 'journey.weather.cold' }, +} + +function groupByDate(entries: JourneyEntry[]): Map { + const groups = new Map() + for (const e of entries) { + const d = e.entry_date + if (!groups.has(d)) groups.set(d, []) + groups.get(d)!.push(e) + } + return groups +} + +function formatDate(d: string): { weekday: string; month: string; day: number } { + const date = new Date(d + 'T00:00:00') + return { + weekday: date.toLocaleDateString('en', { weekday: 'long' }), + month: date.toLocaleDateString('en', { month: 'long' }), + day: date.getDate(), + } +} + +function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'thumbnail'): string { + if (p.provider === 'local') { + return `/uploads/${p.file_path}` + } + // Immich / Synology — stream through the existing memories proxy + // tripId=0 is a placeholder, the proxy uses owner_id to find credentials + const kind = size === 'thumbnail' ? 'thumbnail' : 'original' + return `/api/integrations/memories/${p.provider}/assets/0/${p.asset_id}/${p.owner_id}/${kind}` +} + +export default function JourneyDetailPage() { + const { id } = useParams() + const navigate = useNavigate() + const toast = useToast() + const { t } = useTranslation() + const { current, loading, loadJourney, updateEntry, deleteEntry, uploadPhotos, deletePhoto } = useJourneyStore() + const mapRef = useRef(null) + const fullMapRef = useRef(null) + const [activeLocationId, setActiveLocationId] = useState(null) + + const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline') + const [editingEntry, setEditingEntry] = useState(null) + const [lightbox, setLightbox] = useState<{ photos: { id: number; src: string; caption?: string | null; provider?: string; asset_id?: string | null; owner_id?: number | null }[]; index: number } | null>(null) + const [deleteTarget, setDeleteTarget] = useState(null) + const [showInvite, setShowInvite] = useState(false) + const [showAddTrip, setShowAddTrip] = useState(false) + const [unlinkTrip, setUnlinkTrip] = useState<{ trip_id: number; title: string } | null>(null) + const [showSettings, setShowSettings] = useState(false) + + useEffect(() => { + if (id) loadJourney(Number(id)) + }, [id]) + + // WebSocket real-time updates + useEffect(() => { + if (!id) return + const journeyId = Number(id) + const handler = (event: Record) => { + const type = event.type as string + if (!type?.startsWith('journey:')) return + if (event.journeyId !== journeyId) return + // reload journey data on any change from other contributors + loadJourney(journeyId) + } + addListener(handler) + return () => removeListener(handler) + }, [id]) + + // scroll sync with map + const observerRef = useRef(null) + const setupObserver = useCallback(() => { + observerRef.current?.disconnect() + observerRef.current = new IntersectionObserver((entries) => { + for (const e of entries) { + if (e.isIntersecting) { + const entryId = e.target.getAttribute('data-entry-id') + if (entryId) mapRef.current?.highlightMarker(entryId) + } + } + }, { threshold: 0.5 }) + + document.querySelectorAll('[data-entry-id]').forEach(el => { + observerRef.current?.observe(el) + }) + }, []) + + useEffect(() => { + if (current?.entries?.length) { + setTimeout(setupObserver, 300) + } + return () => observerRef.current?.disconnect() + }, [current?.entries, setupObserver]) + + const handleMarkerClick = useCallback((entryId: string) => { + const el = document.querySelector(`[data-entry-id="${entryId}"]`) + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }) + }, []) + + const handleLocationClick = useCallback((id: string) => { + setActiveLocationId(id) + }, []) + + const mapEntries = useMemo( + () => (current?.entries || []).filter(e => e.location_lat && e.location_lng), + [current?.entries] + ) + + const tripDates = useMemo(() => { + const dates = new Set() + if (!current?.trips) return dates + for (const trip of current.trips) { + if (!trip.start_date || !trip.end_date) continue + const start = new Date(trip.start_date + 'T00:00:00') + const end = new Date(trip.end_date + 'T00:00:00') + for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { + dates.add(d.toISOString().split('T')[0]) + } + } + return dates + }, [current?.trips]) + + if (loading || !current) { + return ( +
    + +
    +
    +
    +
    + ) + } + + const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]') + const dayGroups = groupByDate(timelineEntries) + const sortedDates = [...dayGroups.keys()].sort() + + return ( +
    + +
    +
    + + {/* Back link — desktop */} + + + {/* Hero card — full width */} +
    +
    + {current.cover_image && ( +
    + +
    +
    + )} +
    + +
    + {/* Desktop: badges */} +
    + {current.status === 'active' && ( +
    + + Live +
    + )} +
    + + Synced with Trips +
    +
    + {/* Mobile: back button on the left */} + +
    + + + +
    +
    + +
    +

    {current.title}

    + {current.subtitle &&

    {current.subtitle}

    } +
    + +
    +
    + {[ + { value: sortedDates.length, label: t('journey.stats.days') }, + { value: current.stats.cities, label: t('journey.stats.cities') }, + { value: current.stats.entries, label: t('journey.stats.entries') }, + { value: current.stats.photos, label: t('journey.stats.photos') }, + ].map(s => ( +
    + {s.value} + {s.label} +
    + ))} +
    +
    +
    +
    + + {/* Main grid */} +
    + + {/* Left column */} +
    + {/* View Controls */} +
    +
    + {[ + { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') }, + { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') }, + { id: 'map' as const, icon: MapPin, label: t('journey.share.map') }, + ].map(v => ( + + ))} +
    + {view === 'timeline' && ( + + )} +
    + + {/* Timeline */} + {view === 'timeline' && ( +
    + {sortedDates.length === 0 && ( +
    +
    + +
    +

    No entries yet

    +

    Add a trip to get started with skeleton entries

    +
    + )} + + {sortedDates.map((date, dayIdx) => { + const entries = dayGroups.get(date)! + const fd = formatDate(date) + const locations = [...new Set(entries.map(e => e.location_name).filter(Boolean))] + + return ( +
    +
    +
    +
    + {dayIdx + 1} +
    +
    +

    {fd.weekday}, {fd.month} {fd.day}

    +
    +
    +
    + {entries.length} {t('journey.synced.places')} +
    +
    + + {entries.map(entry => ( +
    + {entry.type === 'skeleton' ? ( + setEditingEntry(entry)} /> + ) : entry.type === 'checkin' ? ( + setEditingEntry(entry)} /> + ) : ( + setEditingEntry(entry)} + onDelete={() => setDeleteTarget(entry)} + onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })} + /> + )} +
    + ))} +
    + ) + })} +
    + )} + + {/* Gallery View */} + {view === 'gallery' && ( + setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })} + onRefresh={() => loadJourney(Number(id))} + /> + )} + + {/* Full Map View */} + {view === 'map' &&
    } +
    + + {/* Right sidebar — hidden on mobile */} +
    + {/* Map panel */} +
    + ({ + id: String(e.id), + lat: e.location_lat!, + lng: e.location_lng!, + title: e.title || '', + mood: e.mood, + created_at: e.entry_date, + entry_date: e.entry_date, + })) as any} + height={240} + onMarkerClick={(id) => handleMarkerClick(id)} + /> +
    + {mapEntries.length} {t('journey.stats.places')} +
    +
    + + {/* Stats panel */} +
    +
    {t('journey.detail.journeyStats')}
    +
    + {[ + { 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') }, + ].map(s => ( +
    +
    {s.value}
    +
    {s.label}
    +
    + ))} +
    +
    + + {/* Synced Trips panel */} +
    +
    + {t('journey.detail.syncedTrips')} + +
    +
    + {current.trips.map((trip: any) => ( +
    navigate(`/trips/${trip.trip_id}`)} + className="group flex items-center gap-2.5 p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 cursor-pointer" + > +
    +
    +
    {trip.title}
    +
    + {trip.place_count || 0} places + {t('journey.synced.synced')} +
    +
    + + +
    + ))} + {current.trips.length === 0 && ( +

    {t('journey.detail.noTripsLinked')}

    + )} +
    +
    + + {/* Contributors panel */} +
    +
    + {t('journey.detail.contributors')} + +
    +
    + {current.contributors.map((c: any) => ( +
    +
    + {(c.username || '?')[0].toUpperCase()} +
    +
    +
    {c.username}
    +
    + + {c.role} + +
    + ))} +
    +
    +
    +
    +
    +
    + + {/* Entry Editor */} + {editingEntry && ( + e.photos || [])} + onClose={() => setEditingEntry(null)} + onSave={async (data) => { + let entryId = editingEntry.id + if (editingEntry.id === 0) { + const created = await useJourneyStore.getState().createEntry(current.id, data) + entryId = created.id + } else { + await updateEntry(editingEntry.id, data) + } + return entryId + }} + onUploadPhotos={async (entryId, formData) => { + return await uploadPhotos(entryId, formData) + }} + onDone={() => { + setEditingEntry(null) + loadJourney(Number(id)) + }} + /> + )} + + {/* Journey Settings */} + {showSettings && ( + setShowSettings(false)} + onSaved={() => { setShowSettings(false); loadJourney(Number(id)) }} + onOpenInvite={() => { setShowSettings(false); setShowInvite(true) }} + /> + )} + + {/* Add Trip Dialog */} + {showAddTrip && current && ( + t.trip_id)} + onClose={() => setShowAddTrip(false)} + onAdded={() => { setShowAddTrip(false); loadJourney(Number(id)) }} + /> + )} + + {/* Contributor Invite Dialog */} + {showInvite && ( + c.user_id)} + onClose={() => setShowInvite(false)} + onInvited={() => { setShowInvite(false); loadJourney(Number(id)) }} + /> + )} + + {/* Delete confirm */} + setDeleteTarget(null)} + onConfirm={async () => { + if (!deleteTarget) return + await deleteEntry(deleteTarget.id) + setDeleteTarget(null) + loadJourney(Number(id)) + }} + title="Delete Entry" + message={`Delete "${deleteTarget?.title || 'this entry'}"? This cannot be undone.`} + confirmLabel="Delete" + danger + /> + + {/* Unlink Trip confirm */} + setUnlinkTrip(null)} + onConfirm={async () => { + if (!unlinkTrip || !current) return + try { + await journeyApi.removeTrip(current.id, unlinkTrip.trip_id) + toast.success(t('journey.trips.tripUnlinked')) + setUnlinkTrip(null) + loadJourney(Number(id)) + } catch { + toast.error(t('journey.trips.unlinkFailed')) + } + }} + title="Unlink Trip" + message={`Unlink "${unlinkTrip?.title}"? All synced entries and photos from this trip will be permanently deleted. This cannot be undone.`} + confirmLabel="Unlink" + danger + /> + + {/* Lightbox */} + {lightbox && ( + ({ id: p.id.toString(), src: p.src, caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id }))} + startIndex={lightbox.index} + onClose={() => setLightbox(null)} + /> + )} +
    + ) +} + +// ── Map View ────────────────────────────────────────────────────────────── + +function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRef, onLocationClick }: { + entries: JourneyEntry[] + mapEntries: JourneyEntry[] + sortedDates: string[] + activeLocationId: string | null + fullMapRef: React.RefObject + onLocationClick: (id: string) => void +}) { + const { t } = useTranslation() + // group map entries by date + const byDate = new Map() + mapEntries.forEach((e, i) => { + const d = e.entry_date + if (!byDate.has(d)) byDate.set(d, []) + byDate.get(d)!.push({ entry: e, globalIdx: i }) + }) + const dates = [...byDate.keys()].sort() + + // find first and last entry indices + const firstId = mapEntries[0]?.id + const lastId = mapEntries[mapEntries.length - 1]?.id + + const mapItems = useMemo(() => mapEntries.map(e => ({ + id: String(e.id), + lat: e.location_lat!, + lng: e.location_lng!, + title: e.title || '', + mood: e.mood, + entry_date: e.entry_date, + })), [mapEntries]) + + return ( +
    + + + {/* Locations list */} +
    + {/* Stats header */} + {mapEntries.length > 0 && ( +
    + {[ + { value: mapEntries.length, label: t('journey.stats.places') }, + { value: dates.length, label: t('journey.stats.days') }, + { value: entries.filter(e => e.type === 'entry').length, label: 'Stories' }, + ].map(s => ( +
    +
    {s.value}
    +
    {s.label}
    +
    + ))} +
    + )} + + {/* Day groups */} +
    + {dates.map((date, dayIdx) => { + const items = byDate.get(date)! + const fd = formatDate(date) + + return ( +
    + {/* Day separator */} +
    + Day {dayIdx + 1} + {fd.month} {fd.day} +
    +
    + + {/* Location items */} + {items.map(({ entry: e, globalIdx }, itemIdx) => { + const isActive = activeLocationId === String(e.id) + const isFirst = e.id === firstId + const isLast = e.id === lastId + const showConnector = itemIdx < items.length - 1 + + return ( +
    +
    onLocationClick(String(e.id))} + className={`flex items-center gap-3 p-3 rounded-[14px] cursor-pointer transition-all ${ + isActive + ? 'bg-zinc-100 dark:bg-zinc-800 border border-zinc-900 dark:border-zinc-100 translate-x-0.5' + : 'bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500 hover:translate-x-0.5' + }`} + > + {/* Number badge */} +
    + {globalIdx + 1} +
    + + {/* Info */} +
    +
    + {e.title || e.location_name} +
    +
    + {e.location_name}{e.entry_time ? ` · ${e.entry_time}` : ''} +
    +
    + + {/* Chevron */} + +
    + + {/* Connector line */} + {showConnector && ( +
    + )} +
    + ) + })} +
    + ) + })} + +
    +
    +
    + ) +} + +// ── Gallery View ────────────────────────────────────────────────────────── + +function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefresh }: { + entries: JourneyEntry[] + journeyId: number + userId: number + trips: JourneyTrip[] + onPhotoClick: (photos: JourneyPhoto[], index: number) => void + onRefresh: () => void +}) { + const { t } = useTranslation() + const [showPicker, setShowPicker] = useState(false) + const [pickerProvider, setPickerProvider] = useState(null) + const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([]) + const toast = useToast() + + // check which providers are enabled AND connected for the current user + useEffect(() => { + (async () => { + try { + const addonsData = await addonsApi.enabled() + const enabledProviders = (addonsData.addons || []).filter( + (a: any) => a.type === 'photo_provider' && a.enabled + ) + const connected: { id: string; name: string }[] = [] + for (const p of enabledProviders) { + try { + const res = await fetch(`/api/integrations/memories/${p.id}/status`, { credentials: 'include' }) + if (res.ok) { + const status = await res.json() + if (status.connected) connected.push({ id: p.id, name: p.name }) + } + } catch {} + } + setAvailableProviders(connected) + } catch {} + })() + }, []) + + const allPhotos: { photo: JourneyPhoto; entry: JourneyEntry }[] = [] + for (const e of entries) { + for (const p of e.photos) { + allPhotos.push({ photo: p, entry: e }) + } + } + + const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title) + + const browseProvider = (provider: string) => { + setPickerProvider(provider) + setShowPicker(true) + } + + const galleryFileRef = useRef(null) + + const handleGalleryUpload = async (e: React.ChangeEvent) => { + const files = e.target.files + if (!files?.length) return + // find existing "Gallery" entry or create one + let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry') + let entryId = galleryEntry?.id + if (!entryId) { + try { + const entry = await journeyApi.createEntry(journeyId, { + title: t('journey.share.gallery'), + entry_date: new Date().toISOString().split('T')[0], + type: 'entry', + }) + entryId = entry.id + } catch { return } + } + const formData = new FormData() + for (const f of files) formData.append('photos', f) + try { + await journeyApi.uploadPhotos(entryId, formData) + toast.success(`${files.length} photos uploaded`) + onRefresh() + } catch { + toast.error(t('journey.settings.coverFailed')) + } + e.target.value = '' + } + + const handleDeletePhoto = async (photoId: number) => { + try { + await journeyApi.deletePhoto(photoId) + onRefresh() + } catch { + toast.error(t('common.error')) + } + } + + return ( +
    + + + {/* Header */} +
    + {allPhotos.length} photos +
    + + {availableProviders.map(p => ( + + ))} +
    +
    + + {allPhotos.length === 0 ? ( +
    +
    + +
    +

    {t('journey.detail.noPhotos')}

    +

    {t('journey.detail.noPhotosHint')}

    +
    + ) : ( +
    + {allPhotos.map(({ photo, entry }) => ( +
    onPhotoClick(entry.photos, entry.photos.indexOf(photo))} + > + {photo.caption +
    + {/* Delete button */} + + {photo.provider !== 'local' && ( +
    + + + {photo.provider === 'immich' ? 'Immich' : 'Synology'} + +
    + )} + {photo.caption && ( +
    +

    {photo.caption}

    +
    + )} +
    + + {entry.entry_date} + +
    +
    + ))} +
    + )} + + {/* Provider Photo Picker Modal */} + {showPicker && ( + (e.photos || []).filter(p => p.asset_id).map(p => p.asset_id!)))} + onClose={() => setShowPicker(false)} + onAdd={async (assetIds, entryId) => { + let targetId = entryId + if (!targetId) { + try { + const entry = await journeyApi.createEntry(journeyId, { + title: t('journey.share.gallery'), + entry_date: new Date().toISOString().split('T')[0], + type: 'entry', + }) + targetId = entry.id + } catch { return } + } + let added = 0 + for (const assetId of assetIds) { + try { + await journeyApi.addProviderPhoto(targetId, pickerProvider!, assetId) + added++ + } catch {} + } + if (added > 0) { + toast.success(`${added} photos added`) + onRefresh() + } + setShowPicker(false) + }} + /> + )} +
    + ) +} + +// ── Expandable Story ───────────────────────────────────────────────────── + +function ExpandableStory({ story }: { story: string }) { + const [expanded, setExpanded] = useState(false) + const [clamped, setClamped] = useState(false) + const ref = useRef(null) + const measuredRef = useRef(false) + + useEffect(() => { + measuredRef.current = false + }, [story]) + + useEffect(() => { + if (measuredRef.current) return + const el = ref.current + if (el && !expanded) { + setClamped(el.scrollHeight > el.clientHeight) + measuredRef.current = true + } + }) + + return ( +
    +
    { if (clamped || expanded) setExpanded(e => !e) }} + className={`text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed ${ + expanded ? '' : 'line-clamp-3 md:line-clamp-[9]' + } ${clamped || expanded ? 'cursor-pointer md:cursor-auto' : ''}`} + > + +
    + {clamped && !expanded && ( + + )} +
    + ) +} + +// ── Verdict Section (Pros & Cons) ──────────────────────────────────────── + +function VerdictSection({ pros, cons }: { pros: string[]; cons: string[] }) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + // On desktop always show, on mobile toggle + return ( +
    + {/* Header — clickable on mobile */} + + + {/* Collapsed summary on mobile */} + {!open && ( +
    + {pros.length > 0 && ( +
    +
    + +
    + {pros.length} +
    + )} + {cons.length > 0 && ( +
    +
    + +
    + {cons.length} +
    + )} +
    + )} + + {/* Content — always visible on desktop, toggled on mobile */} +
    + {pros.length > 0 && ( +
    +
    +
    + +
    + {t('journey.verdict.lovedIt')} + {pros.length} +
    +
    + {pros.map((p, i) => ( +
    + + {p} +
    + ))} +
    +
    + )} + {cons.length > 0 && ( +
    +
    +
    + +
    + {t('journey.verdict.couldBeBetter')} + {cons.length} +
    +
    + {cons.map((c, i) => ( +
    + + {c} +
    + ))} +
    +
    + )} +
    +
    + ) +} + +// ── Entry Card ──────────────────────────────────────────────────────────── + +function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: { + entry: JourneyEntry + onEdit: () => void + onDelete: () => void + onPhotoClick: (photos: JourneyPhoto[], index: number) => void +}) { + const { t } = useTranslation() + const [menuOpen, setMenuOpen] = useState(false) + const menuBtnRef = useRef(null) + const photos = entry.photos || [] + const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null + const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null + + const prosArr = entry.pros_cons?.pros ?? [] + const consArr = entry.pros_cons?.cons ?? [] + const hasProscons = prosArr.length > 0 || consArr.length > 0 + + return ( +
    + + {/* Hero area: photos with title overlay */} + {photos.length > 0 ? ( +
    + onPhotoClick(photos, idx)} /> + {/* Gradient overlay for title */} +
    + + {/* Badges top-left */} +
    + {entry.location_name && ( + + + {entry.location_name} + + )} + {entry.entry_time && ( + + + {entry.entry_time} + + )} +
    + + {/* Menu top-right */} +
    + + {menuOpen && ( + <> +
    setMenuOpen(false)} /> +
    + + +
    + + )} +
    + + {/* Title on photo */} + {entry.title && ( +
    +

    {entry.title}

    +
    + )} +
    + ) : ( + /* No photos: simple header */ +
    +
    + {entry.location_name && ( + + {entry.location_name} + + )} + {entry.entry_time && ( + + {entry.entry_time} + + )} +
    +
    + + {menuOpen && ( + <> +
    setMenuOpen(false)} /> +
    + + +
    + + )} +
    +
    + )} + +
    + {/* Title (only if no photos — otherwise shown on image) */} + {!photos.length && entry.title && ( +

    {entry.title}

    + )} + {!photos.length && entry.location_name && !entry.title && ( +
    + )} + {entry.story && ( + + )} + + {/* Pros & Cons — "Pros & Cons" style */} + {hasProscons && ( + + )} + + {(mood || weather || (entry.tags && entry.tags.length > 0)) && ( +
    +
    + {mood && } + {weather && } +
    +
    + {entry.tags?.map((tag, i) => ( + {tag} + ))} +
    +
    + )} +
    +
    + ) +} + +function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick: () => void }) { + const { t } = useTranslation() + return ( +
    +
    + +
    +
    +
    + {entry.title || t('journey.detail.newEntry')} +
    +
    + {entry.location_name || ''}{entry.entry_time ? ` · ${entry.entry_time}` : ''} +
    +
    +
    + {t('journey.detail.addEntry')} → +
    +
    + ) +} + +function CheckinCard({ entry, onClick }: { entry: JourneyEntry; onClick: () => void }) { + return ( +
    +
    + +
    +
    +
    + {entry.title} + {entry.location_name && · {entry.location_name}} +
    + {entry.story &&
    {entry.story}
    } +
    +
    + {entry.entry_time && {entry.entry_time}} +
    +
    + ) +} + +function PhotoImg({ photo, className, style, onClick }: { photo: JourneyPhoto; className?: string; style?: React.CSSProperties; onClick?: () => void }) { + const src = photo.provider !== 'local' ? photoUrl(photo, 'original') : photoUrl(photo) + return ( + + ) +} + +function PhotoGrid({ photos, onClick }: { photos: JourneyPhoto[]; onClick: (idx: number) => void }) { + const count = photos.length + if (count === 0) return null + + if (count === 1) { + return ( +
    onClick(0)}> + +
    + ) + } + + if (count === 2) { + return ( +
    + {photos.slice(0, 2).map((p, i) => ( + onClick(i)} /> + ))} +
    + ) + } + + return ( +
    +
    onClick(0)}> + +
    +
    +
    onClick(1)}> + +
    +
    onClick(2)}> + + {count > 3 && ( +
    + + +{count - 3} +
    + )} +
    +
    +
    + ) +} + +function MoodChip({ mood }: { mood: string }) { + const { t } = useTranslation() + const config = MOOD_CONFIG[mood] + if (!config) return null + const Icon = config.icon + return ( +
    + + {t(config.label)} +
    + ) +} + +function WeatherChip({ weather }: { weather: string }) { + const { t } = useTranslation() + const config = WEATHER_CONFIG[weather] + if (!config) return null + const Icon = config.icon + return ( +
    + + {t(config.label)} +
    + ) +} + +// ── Provider Picker ─────────────────────────────────────────────────────── + +function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd }: { + provider: string + userId: number + entries: JourneyEntry[] + trips: JourneyTrip[] + existingAssetIds: Set + onClose: () => void + onAdd: (assetIds: string[], entryId: number | null) => Promise +}) { + const { t } = useTranslation() + const [filter, setFilter] = useState<'trip' | 'custom' | 'album'>('trip') + const [photos, setPhotos] = useState([]) + const [albums, setAlbums] = useState([]) + const [selectedAlbum, setSelectedAlbum] = useState(null) + const [loading, setLoading] = useState(false) + const [selected, setSelected] = useState>(new Set()) + const [customFrom, setCustomFrom] = useState('') + const [customTo, setCustomTo] = useState('') + const [targetEntryId, setTargetEntryId] = useState(null) + const [addToOpen, setAddToOpen] = useState(false) + + // compute trip range + const tripRange = useMemo(() => { + let from = '', to = '' + for (const t of trips) { + if (t.start_date && (!from || t.start_date < from)) from = t.start_date + if (t.end_date && (!to || t.end_date > to)) to = t.end_date + } + return { from, to } + }, [trips]) + + const searchPhotos = async (from: string, to: string) => { + setLoading(true) + try { + const res = await fetch(`/api/integrations/memories/${provider}/search`, { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from, to }), + }) + if (res.ok) setPhotos((await res.json()).assets || []) + } catch {} + setLoading(false) + } + + const loadAlbums = async () => { + try { + const res = await fetch(`/api/integrations/memories/${provider}/albums`, { credentials: 'include' }) + if (res.ok) setAlbums((await res.json()).albums || []) + } catch {} + } + + // load on mount / filter change + useEffect(() => { + if (filter === 'trip' && tripRange.from && tripRange.to) { + searchPhotos(tripRange.from, tripRange.to) + } else if (filter === 'album' && albums.length === 0) { + loadAlbums() + } + }, [filter]) + + const handleCustomSearch = () => { + if (customFrom && customTo) searchPhotos(customFrom, customTo) + } + + const toggleAsset = (id: string) => { + setSelected(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id); else next.add(id) + return next + }) + } + + const targetLabel = targetEntryId + ? entries.find(e => e.id === targetEntryId)?.title || entries.find(e => e.id === targetEntryId)?.entry_date || t('journey.stats.entries') + : 'Gallery' + + return ( +
    +
    + + {/* Header */} +
    +

    + {provider === 'immich' ? 'Immich' : 'Synology Photos'} +

    + +
    + + {/* Filter bar */} +
    + {/* Tabs */} +
    + {[ + { id: 'trip' as const, label: t('journey.trips.link') }, + { id: 'custom' as const, label: t('common.edit') }, + { id: 'album' as const, label: t('journey.share.gallery') }, + ].map(f => ( + + ))} +
    + + {/* Filter content — always visible row */} +
    + {filter === 'trip' && ( +
    + {tripRange.from && tripRange.to ? ( + <> + + + {new Date(tripRange.from + 'T00:00:00').toLocaleDateString('en', { month: 'short', day: 'numeric' })} + + + + {new Date(tripRange.to + 'T00:00:00').toLocaleDateString('en', { month: 'short', day: 'numeric', year: 'numeric' })} + + + ({Math.ceil((new Date(tripRange.to).getTime() - new Date(tripRange.from).getTime()) / 86400000) + 1} days) + + + ) : ( + {t('journey.trips.noTripsLinkedSettings')} + )} +
    + )} + + {filter === 'custom' && ( +
    +
    + +
    + +
    + )} + + {filter === 'album' && ( +
    + {albums.map((a: any) => ( + + ))} + {albums.length === 0 && !loading && No albums found} +
    + )} +
    +
    + + {/* Add-to */} +
    +
    + Add to + + {addToOpen && ( + <> +
    setAddToOpen(false)} /> +
    + +
    + {entries.map(e => ( + + ))} +
    + + )} +
    +
    + + {/* Photo grid */} +
    + {loading ? ( +
    +
    +
    + ) : photos.length === 0 ? ( +
    +

    + {filter === 'trip' && !tripRange.from ? t('journey.trips.noTripsLinkedSettings') : t('journey.detail.noPhotos')} +

    +
    + ) : ( +
    + {photos.map((asset: any) => { + const isSelected = selected.has(asset.id) + const alreadyAdded = existingAssetIds.has(asset.id) + return ( +
    !alreadyAdded && toggleAsset(asset.id)} + className={`relative aspect-square rounded-lg overflow-hidden ${ + alreadyAdded + ? 'opacity-40 cursor-not-allowed' + : isSelected + ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer' + : 'cursor-pointer' + }`} + > + { + const img = e.currentTarget + const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original` + if (!img.src.includes('/original')) img.src = original + }} + /> + {alreadyAdded && ( +
    + +
    + )} + {isSelected && !alreadyAdded && ( +
    + +
    + )} + {asset.city && ( +
    +

    {asset.city}

    +
    + )} +
    + ) + })} +
    + )} +
    + + {/* Footer */} +
    + + {selected.size} selected + +
    + + +
    +
    +
    +
    + ) +} + +// ── Date Picker ─────────────────────────────────────────────────────────── + +function DatePicker({ value, onChange, tripDates }: { + value: string + onChange: (date: string) => void + tripDates?: Set +}) { + const [open, setOpen] = useState(false) + const [viewMonth, setViewMonth] = useState(() => { + const d = value ? new Date(value + 'T00:00:00') : new Date() + return { year: d.getFullYear(), month: d.getMonth() } + }) + + const daysInMonth = new Date(viewMonth.year, viewMonth.month + 1, 0).getDate() + const firstDow = new Date(viewMonth.year, viewMonth.month, 1).getDay() + const monthName = new Date(viewMonth.year, viewMonth.month).toLocaleDateString('en', { month: 'long', year: 'numeric' }) + + const prevMonth = () => { + setViewMonth(p => p.month === 0 ? { year: p.year - 1, month: 11 } : { ...p, month: p.month - 1 }) + } + const nextMonth = () => { + setViewMonth(p => p.month === 11 ? { year: p.year + 1, month: 0 } : { ...p, month: p.month + 1 }) + } + + const pad = (n: number) => String(n).padStart(2, '0') + + const cells: (number | null)[] = [] + for (let i = 0; i < firstDow; i++) cells.push(null) + for (let d = 1; d <= daysInMonth; d++) cells.push(d) + + const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString('en', { month: 'short', day: 'numeric', year: 'numeric' }) : 'Select date' + + return ( +
    + + + {open && ( + <> +
    setOpen(false)} /> +
    + {/* Month nav */} +
    + + {monthName} + +
    + + {/* Weekday headers */} +
    + {['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(d => ( +
    {d}
    + ))} +
    + + {/* Day grid */} +
    + {cells.map((day, i) => { + if (day === null) return
    + const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}` + const isSelected = dateStr === value + const isTrip = tripDates?.has(dateStr) + const isToday = dateStr === new Date().toISOString().split('T')[0] + + return ( + + ) + })} +
    +
    + + )} +
    + ) +} + +function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSave, onUploadPhotos, onDone }: { + entry: JourneyEntry + journeyId: number + tripDates: Set + galleryPhotos: JourneyPhoto[] + onClose: () => void + onSave: (data: Record) => Promise + onUploadPhotos: (entryId: number, formData: FormData) => Promise + onDone: () => void +}) { + const { t } = useTranslation() + const [title, setTitle] = useState(entry.title || '') + const [story, setStory] = useState(entry.story || '') + const [entryDate, setEntryDate] = useState(entry.entry_date || new Date().toISOString().split('T')[0]) + const [entryTime, setEntryTime] = useState(entry.entry_time || '') + const [locationName, setLocationName] = useState(entry.location_name || '') + const [locationLat, setLocationLat] = useState(entry.location_lat ?? null) + const [locationLng, setLocationLng] = useState(entry.location_lng ?? null) + const [locationQuery, setLocationQuery] = useState('') + const [locationResults, setLocationResults] = useState<{ name: string; address?: string; lat: number; lng: number }[]>([]) + const [locationSearching, setLocationSearching] = useState(false) + const [showLocationResults, setShowLocationResults] = useState(false) + const locationTimerRef = useRef | null>(null) + const [mood, setMood] = useState(entry.mood || '') + const [weather, setWeather] = useState(entry.weather || '') + const [pros, setPros] = useState(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : ['']) + const [cons, setCons] = useState(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : ['']) + const [saving, setSaving] = useState(false) + const [photos, setPhotos] = useState(entry.photos || []) + const [pendingFiles, setPendingFiles] = useState([]) + const [pendingLinkIds, setPendingLinkIds] = useState([]) + const [showGalleryPick, setShowGalleryPick] = useState(false) + const fileRef = useRef(null) + const storyRef = useRef(null) + + const handleSave = async () => { + setSaving(true) + try { + const entryId = await onSave({ + title: title || null, + story: story || null, + entry_date: entryDate, + entry_time: entryTime || null, + location_name: locationName || null, + location_lat: locationLat, + location_lng: locationLng, + mood: mood || null, + weather: weather || null, + pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) }, + type: (entry.type === 'skeleton' && story.trim()) ? 'entry' : undefined, + }) + // upload queued files after entry is created + if (pendingFiles.length > 0 && entryId) { + const formData = new FormData() + for (const f of pendingFiles) formData.append('photos', f) + await onUploadPhotos(entryId, formData) + } + // link gallery photos that were picked before save + if (pendingLinkIds.length > 0 && entryId) { + for (const photoId of pendingLinkIds) { + try { await journeyApi.linkPhoto(entryId, photoId) } catch {} + } + } + onDone() + } finally { + setSaving(false) + } + } + + const handleFileChange = async (e: React.ChangeEvent) => { + const files = e.target.files + if (!files?.length) return + if (entry.id === 0) { + // queue files for upload after save + setPendingFiles(prev => [...prev, ...Array.from(files)]) + } else { + const formData = new FormData() + for (const f of files) formData.append('photos', f) + const newPhotos = await onUploadPhotos(entry.id, formData) + if (newPhotos?.length) setPhotos(prev => [...prev, ...newPhotos]) + } + } + + return ( +
    +
    + +
    +

    {entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}

    + +
    + +
    + setTitle(e.target.value)} + placeholder={t('journey.editor.titlePlaceholder')} + className="w-full text-[20px] font-medium bg-transparent border-0 border-b border-transparent focus:border-zinc-300 dark:focus:border-zinc-600 outline-none text-zinc-900 dark:text-white placeholder:text-zinc-400 pb-2" + /> + +
    + { (e.target as HTMLInputElement).value = '' }} className="hidden" /> +
    + + {galleryPhotos.length > 0 && ( + + )} +
    + + {/* Gallery picker — directly below buttons */} + {showGalleryPick && ( +
    +
    + {galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).map(gp => ( +
    { + if (entry.id > 0) { + try { + const linked = await journeyApi.linkPhoto(entry.id, gp.id) + if (linked) setPhotos(prev => [...prev, linked]) + } catch {} + } else { + setPendingLinkIds(prev => [...prev, gp.id]) + setPhotos(prev => [...prev, gp]) + } + }} + className="aspect-square rounded-lg overflow-hidden cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all" + > + { if (gp.provider !== 'local') { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig } }} /> +
    + ))} + {galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).length === 0 && ( +
    {t('journey.editor.allPhotosAdded')}
    + )} +
    +
    + )} + {(photos.length > 0 || pendingFiles.length > 0) && ( +
    +
    + {photos.map((p, idx) => ( +
    1 ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-1 dark:ring-offset-zinc-900' : ''}`}> + { if (p.provider !== 'local') { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig } }} /> + {idx === 0 && photos.length > 1 && ( + {t('journey.editor.photoFirst')} + )} + {idx > 0 && photos.length > 1 && ( + + )} + +
    + ))} + {pendingFiles.map((f, i) => ( +
    + + +
    + ))} +
    +
    + )} +
    + +
    + +