feat: Journey addon — travel journal with entries, photos, public sharing & PDF export

- 5-table schema (journeys, entries, photos, trips, contributors) with migrations 87-91
- Trip-to-Journey sync engine with skeleton entries and photo sync
- Full CRUD API for journeys, entries, photos with Immich/Synology integration
- Timeline, Gallery and Map views with entry editor (markdown, mood, weather, pros/cons)
- Journey frontpage with hero card, stats and trip suggestions
- Public share links with token-based access and photo proxy
- PDF photo book export (Polarsteps-inspired)
- Dashboard redesign: mobile greeting, live trip hero, quick actions, unified card design
- BottomNav profile sheet with settings/admin/logout
- DayPlan mobile inline place picker
- TripFormModal members management
- Vacay calendar trip date indicator dots
- Fix contributor photo access (403) for journey Immich/Synology photos
- Trip deletion cleanup for journey skeleton entries
- i18n: 231 new keys across all 14 languages (native translations, no fallbacks)
This commit is contained in:
Maurice
2026-04-11 19:01:34 +02:00
parent 0df90086bf
commit 13956804c2
56 changed files with 10843 additions and 332 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>TREK</title> <title>TREK</title>
<!-- PWA / iOS --> <!-- PWA / iOS -->
+13 -87
View File
@@ -12,6 +12,7 @@
"axios": "^1.6.7", "axios": "^1.6.7",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"marked": "^18.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-dropzone": "^14.4.1", "react-dropzone": "^14.4.1",
@@ -1983,9 +1984,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2003,9 +2001,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2023,9 +2018,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2060,9 +2052,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2097,9 +2086,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2123,9 +2109,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2149,9 +2132,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2198,9 +2178,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2855,9 +2832,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2875,9 +2849,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2895,9 +2866,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2915,9 +2883,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2935,9 +2900,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2955,9 +2917,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3218,9 +3177,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3235,9 +3191,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3252,9 +3205,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3269,9 +3219,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3286,9 +3233,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3303,9 +3247,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3320,9 +3261,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3337,9 +3275,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3354,9 +3289,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3371,9 +3303,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3388,9 +3317,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7131,9 +7057,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7155,9 +7078,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7179,9 +7099,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7203,9 +7120,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7434,6 +7348,18 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+1
View File
@@ -19,6 +19,7 @@
"axios": "^1.6.7", "axios": "^1.6.7",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"marked": "^18.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-dropzone": "^14.4.1", "react-dropzone": "^14.4.1",
+28 -2
View File
@@ -10,9 +10,13 @@ import AdminPage from './pages/AdminPage'
import SettingsPage from './pages/SettingsPage' import SettingsPage from './pages/SettingsPage'
import VacayPage from './pages/VacayPage' import VacayPage from './pages/VacayPage'
import AtlasPage from './pages/AtlasPage' 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 SharedTripPage from './pages/SharedTripPage'
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx' import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
import { ToastContainer } from './components/shared/Toast' import { ToastContainer } from './components/shared/Toast'
import BottomNav from './components/Layout/BottomNav'
import { TranslationProvider, useTranslation } from './i18n' import { TranslationProvider, useTranslation } from './i18n'
import { authApi } from './api/client' import { authApi } from './api/client'
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore' import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
@@ -60,7 +64,12 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps
return <Navigate to="/dashboard" replace /> return <Navigate to="/dashboard" replace />
} }
return <>{children}</> return (
<div className="flex flex-col h-screen md:block md:h-auto">
<div className="flex-1 overflow-y-auto md:overflow-visible">{children}</div>
<BottomNav />
</div>
)
} }
function RootRedirect() { function RootRedirect() {
@@ -82,7 +91,7 @@ export default function App() {
const { loadSettings } = useSettingsStore() const { loadSettings } = useSettingsStore()
useEffect(() => { useEffect(() => {
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/login')) { if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
loadUser() 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<string, PermissionLevel> }) => { 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<string, PermissionLevel> }) => {
@@ -162,6 +171,7 @@ export default function App() {
<Route path="/" element={<RootRedirect />} /> <Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/shared/:token" element={<SharedTripPage />} /> <Route path="/shared/:token" element={<SharedTripPage />} />
<Route path="/public/journey/:token" element={<JourneyPublicPage />} />
<Route path="/register" element={<LoginPage />} /> <Route path="/register" element={<LoginPage />} />
<Route <Route
path="/dashboard" path="/dashboard"
@@ -219,6 +229,22 @@ export default function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/journey"
element={
<ProtectedRoute>
<JourneyPage />
</ProtectedRoute>
}
/>
<Route
path="/journey/:id"
element={
<ProtectedRoute>
<JourneyDetailPage />
</ProtectedRoute>
}
/>
<Route <Route
path="/notifications" path="/notifications"
element={ element={
+43 -1
View File
@@ -26,7 +26,7 @@ apiClient.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') { 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 const currentPath = window.location.pathname + window.location.search
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath) window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
} }
@@ -208,6 +208,48 @@ export const addonsApi = {
enabled: () => apiClient.get('/addons').then(r => r.data), 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<string, unknown>) => 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<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
updateEntry: (entryId: number, data: Record<string, unknown>) => 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<string, unknown>) => 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 = { export const mapsApi = {
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data), 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), details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
+2 -2
View File
@@ -4,10 +4,10 @@ import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import { useToast } from '../shared/Toast' 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 = { 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 { interface Addon {
+1 -1
View File
@@ -762,7 +762,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
)} )}
{/* Composer */} {/* Composer */}
<div style={{ flexShrink: 0, padding: '8px 12px calc(12px + env(safe-area-inset-bottom, 0px))', borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }}> <div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }} className="pb-[96px] md:pb-3">
{/* Reply preview */} {/* Reply preview */}
{replyTo && ( {replyTo && (
<div style={{ <div style={{
@@ -0,0 +1,69 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
interface Props {
text: string
dark?: boolean
}
export default function JournalBody({ text, dark }: Props) {
return (
<div className="journal-body" style={{
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 1.6,
color: 'inherit',
}}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => <h1 style={{ fontFamily: 'inherit', fontSize: '1.3em', fontWeight: 700, margin: '16px 0 6px', lineHeight: 1.3 }}>{children}</h1>,
h2: ({ children }) => <h2 style={{ fontFamily: 'inherit', fontSize: '1.15em', fontWeight: 600, margin: '14px 0 4px', lineHeight: 1.3 }}>{children}</h2>,
h3: ({ children }) => <h3 style={{ fontFamily: 'inherit', fontSize: '1.05em', fontWeight: 600, margin: '12px 0 4px', lineHeight: 1.4 }}>{children}</h3>,
p: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
blockquote: ({ children }) => (
<blockquote style={{
borderLeft: `3px solid var(--journal-accent)`,
paddingLeft: 16, margin: '12px 0',
fontStyle: 'italic', color: 'var(--journal-muted)',
}}>{children}</blockquote>
),
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--journal-accent)', textDecoration: 'underline', textUnderlineOffset: 2 }}>
{children}
</a>
),
ul: ({ children }) => <ul style={{ paddingLeft: 20, margin: '8px 0' }}>{children}</ul>,
ol: ({ children }) => <ol style={{ paddingLeft: 20, margin: '8px 0' }}>{children}</ol>,
li: ({ children }) => <li style={{ margin: '4px 0' }}>{children}</li>,
strong: ({ children }) => <strong style={{ fontWeight: 600 }}>{children}</strong>,
em: ({ children }) => <em>{children}</em>,
hr: () => <hr style={{ border: 'none', borderTop: '1px solid var(--journal-border)', margin: '20px 0' }} />,
code: ({ children, className }) => {
const isBlock = className?.includes('language-')
if (isBlock) {
return (
<pre style={{
background: dark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)',
borderRadius: 8, padding: 14, overflowX: 'auto',
fontSize: 13, fontFamily: 'monospace', margin: '12px 0',
}}>
<code>{children}</code>
</pre>
)
}
return (
<code style={{
background: dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
borderRadius: 4, padding: '2px 5px', fontSize: '0.9em', fontFamily: 'monospace',
}}>{children}</code>
)
},
}}
>
{text}
</ReactMarkdown>
</div>
)
}
@@ -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 `<div style="transform:scale(${scale});transition:transform 0.2s ease;${shadow};transform-origin:bottom center">
<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="2"/>
<circle cx="14" cy="13" r="8" fill="${fill}"/>
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
</svg>
</div>`
}
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
const JourneyMap = forwardRef<JourneyMapHandle, Props>(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<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(null)
const markersRef = useRef<Map<string, L.Marker>>(new Map())
const itemsRef = useRef<MapMarkerItem[]>([])
const highlightedRef = useRef<string | null>(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 (
<div style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}>
<div
ref={containerRef}
style={{ width: '100%', height: '100%' }}
/>
<div style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 400, display: 'flex', flexDirection: 'column', gap: 4 }}>
<button
onClick={zoomIn}
style={{
width: 32, height: 32, borderRadius: 8,
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
backdropFilter: 'blur(8px)',
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
color: dark ? '#fff' : '#18181B',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
}}
>+</button>
<button
onClick={zoomOut}
style={{
width: 32, height: 32, borderRadius: 8,
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
backdropFilter: 'blur(8px)',
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
color: dark ? '#fff' : '#18181B',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
}}
></button>
</div>
</div>
)
})
export default JourneyMap
@@ -0,0 +1,81 @@
import { Bold, Italic, Heading2, Link, Quote, List, ListOrdered, Minus } from 'lucide-react'
interface Props {
textareaRef: React.RefObject<HTMLTextAreaElement | null>
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 (
<div style={{
display: 'flex', gap: 2, padding: '6px 4px',
borderBottom: `1px solid var(--journal-border)`,
overflowX: 'auto',
}}>
{ACTIONS.map(a => (
<button
key={a.label}
type="button"
title={a.label}
onClick={() => apply(a.action)}
style={{
width: 32, height: 32, borderRadius: 6,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'none', border: 'none',
color: 'var(--journal-muted)', cursor: 'pointer',
flexShrink: 0,
}}
onMouseEnter={e => e.currentTarget.style.background = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
>
<a.icon size={15} />
</button>
))}
</div>
)
}
@@ -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<ExifData | null>(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<ExifData> = {}
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 (
<div
style={{
position: 'fixed', inset: 0, zIndex: 500,
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
display: 'flex', flexDirection: 'column',
}}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
{/* Top bar */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px 16px', flexShrink: 0 }}>
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 13 }}>
{idx + 1} / {photos.length}
</span>
<button onClick={onClose} style={{
background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: '50%',
width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}>
<X size={18} />
</button>
</div>
{/* Photo */}
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', overflow: 'hidden' }}>
{hasPrev && (
<button onClick={prev} className="hidden sm:flex" style={{
position: 'absolute', left: 12, zIndex: 2,
width: 40, height: 40, borderRadius: '50%',
background: 'rgba(255,255,255,0.1)', border: 'none',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}>
<ChevronLeft size={20} />
</button>
)}
<div style={{ position: 'relative', display: 'inline-flex' }}>
<img
key={photo.id}
src={photo.src}
alt={photo.caption || ''}
style={{
maxWidth: '90vw', maxHeight: 'calc(100vh - 140px)',
objectFit: 'contain', borderRadius: 4,
animation: 'fadeIn 0.15s ease',
}}
/>
{/* EXIF metadata overlay */}
{exif && !exifLoading && (
<div style={{
position: 'absolute', bottom: 12, right: 12,
background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(16px)',
borderRadius: 12, padding: '10px 14px',
color: 'rgba(255,255,255,0.85)', fontSize: 11,
display: 'flex', flexDirection: 'column', gap: 4,
maxWidth: 220, border: '1px solid rgba(255,255,255,0.08)',
}}>
{exif.camera && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Camera size={11} style={{ opacity: 0.6, flexShrink: 0 }} />
<span style={{ fontWeight: 500 }}>{exif.camera}</span>
</div>
)}
{exif.lens && (
<div style={{ fontSize: 10, color: 'rgba(255,255,255,0.55)', paddingLeft: 17 }}>{exif.lens}</div>
)}
{(exif.focalLength || exif.aperture || exif.shutter || exif.iso) && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 2 }}>
<Aperture size={11} style={{ opacity: 0.6, flexShrink: 0 }} />
<span style={{ fontWeight: 400, letterSpacing: '0.02em' }}>
{[exif.focalLength, exif.aperture, exif.shutter, exif.iso ? `ISO ${exif.iso}` : ''].filter(Boolean).join(' · ')}
</span>
</div>
)}
</div>
)}
</div>
{hasNext && (
<button onClick={next} className="hidden sm:flex" style={{
position: 'absolute', right: 12, zIndex: 2,
width: 40, height: 40, borderRadius: '50%',
background: 'rgba(255,255,255,0.1)', border: 'none',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}>
<ChevronRight size={20} />
</button>
)}
</div>
{/* Caption */}
{photo.caption && (
<div style={{ textAlign: 'center', padding: '12px 24px 20px', flexShrink: 0 }}>
<p style={{
fontFamily: 'var(--font-system)', fontSize: 14, fontStyle: 'italic',
color: 'rgba(255,255,255,0.7)', margin: 0, lineHeight: 1.5,
}}>{photo.caption}</p>
</div>
)}
</div>
)
}
@@ -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<string, { bg: string; fg: string; darkBg: string; darkFg: string }> = {
'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' }
}
@@ -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()
}
+161
View File
@@ -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<string, { to: string; label: string; icon: LucideIcon }> = {
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 (
<>
<nav
className="md:hidden sticky bottom-0 border-t border-zinc-200 dark:border-zinc-800 flex justify-around items-start pt-3 z-50 mt-auto flex-shrink-0"
style={{
height: 'calc(84px + env(safe-area-inset-bottom, 0px))',
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
background: 'rgba(255,255,255,0.96)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
}}
>
{items.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex flex-col items-center gap-1 px-3 py-1 min-w-[60px] ${
isActive ? 'text-zinc-900 dark:text-white' : 'text-zinc-400 dark:text-zinc-500'
}`
}
>
<Icon size={22} strokeWidth={2} />
<span className="text-[10px] font-medium">{label}</span>
</NavLink>
))}
<button
onClick={() => setShowProfile(true)}
className="flex flex-col items-center gap-1 px-3 py-1 min-w-[60px] text-zinc-400 dark:text-zinc-500"
>
<User size={22} strokeWidth={2} />
<span className="text-[10px] font-medium">{t("nav.profile")}</span>
</button>
</nav>
{showProfile && <ProfileSheet onClose={() => 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 (
<div className="fixed inset-0 z-[300] md:hidden" onClick={onClose}>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
{/* Sheet */}
<div
className="absolute bottom-0 left-0 right-0 bg-white dark:bg-zinc-900 rounded-t-2xl overflow-hidden"
style={{ animation: 'slideUp 0.25s ease-out', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
onClick={e => e.stopPropagation()}
>
{/* Handle */}
<div className="flex justify-center pt-3 pb-2">
<div className="w-10 h-1 rounded-full bg-zinc-300 dark:bg-zinc-700" />
</div>
{/* User info */}
<div className="px-6 pb-4 pt-1">
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[16px] font-bold">
{(user?.username || '?')[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-[15px] font-semibold text-zinc-900 dark:text-white">{user?.username}</p>
<p className="text-[12px] text-zinc-500 truncate">{user?.email}</p>
</div>
{user?.role === 'admin' && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-semibold text-zinc-600 dark:text-zinc-400 uppercase tracking-wide">
<Shield size={10} /> Admin
</span>
)}
</div>
</div>
<div className="h-px bg-zinc-100 dark:bg-zinc-800 mx-4" />
{/* Links */}
<div className="py-2 px-2">
<button
onClick={() => handleNav('/settings')}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
>
<Settings size={18} className="text-zinc-500" />
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t("nav.bottomSettings")}</span>
</button>
{user?.role === 'admin' && (
<button
onClick={() => handleNav('/admin')}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
>
<Shield size={18} className="text-zinc-500" />
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t("nav.bottomAdmin")}</span>
</button>
)}
</div>
<div className="h-px bg-zinc-100 dark:bg-zinc-800 mx-4" />
{/* Logout */}
<div className="py-2 px-2">
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-red-50 dark:hover:bg-red-900/20 active:bg-red-100 transition-colors"
>
<LogOut size={18} className="text-red-500" />
<span className="text-[14px] font-medium text-red-600 dark:text-red-400">{t("nav.bottomLogout")}</span>
</button>
</div>
<div className="h-4" />
</div>
</div>
)
}
@@ -0,0 +1,17 @@
interface Props {
title: string
subtitle?: string
actions?: React.ReactNode
}
export default function MobileTopHeader({ title, subtitle, actions }: Props) {
return (
<div className="px-5 pt-4 pb-3 flex justify-between items-center bg-zinc-50 dark:bg-zinc-950 flex-shrink-0 md:hidden">
<div className="flex-1 min-w-0">
<h1 className="text-[28px] font-extrabold text-zinc-900 dark:text-white tracking-tight leading-none">{title}</h1>
{subtitle && <div className="text-xs text-zinc-500 mt-1">{subtitle}</div>}
</div>
{actions && <div className="flex gap-2 items-center flex-shrink-0">{actions}</div>}
</div>
)
}
+3 -3
View File
@@ -5,11 +5,11 @@ import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import { useTranslation } from '../../i18n' 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 type { LucideIcon } from 'lucide-react'
import InAppNotificationBell from './InAppNotificationBell.tsx' import InAppNotificationBell from './InAppNotificationBell.tsx'
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe } const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass }
interface NavbarProps { interface NavbarProps {
tripTitle?: string tripTitle?: string
@@ -75,7 +75,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
touchAction: 'manipulation', touchAction: 'manipulation',
paddingTop: 'env(safe-area-inset-top, 0px)', paddingTop: 'env(safe-area-inset-top, 0px)',
height: 'var(--nav-h)', 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 */} {/* Left side */}
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
{showBack && ( {showBack && (
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
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<string, JourneyEntry[]> {
const groups = new Map<string, JourneyEntry[]>()
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 `<div class="verdict-wrap"><div class="verdict-row">
${pros.length > 0 ? `<div class="verdict-card pros"><div class="verdict-label">Loved it</div><ul>${pros.map(p => `<li>${esc(p)}</li>`).join('')}</ul></div>` : ''}
${cons.length > 0 ? `<div class="verdict-card cons"><div class="verdict-label">Could be better</div><ul>${cons.map(c => `<li>${esc(c)}</li>`).join('')}</ul></div>` : ''}
</div></div>`
}
function renderPhotoBlock(photos: JourneyPhoto[]): string {
if (photos.length === 0) return ''
if (photos.length === 1) {
return `<div class="entry-photo-single"><img src="${pSrc(photos[0])}" /></div>`
}
if (photos.length === 2) {
return `<div class="entry-photo-duo">${photos.map(p => `<div class="photo-cell"><img src="${pSrc(p)}" /></div>`).join('')}</div>`
}
// 3+ photos: hero left + stack right
return `<div class="entry-photo-trio">
<div class="photo-hero"><img src="${pSrc(photos[0])}" /></div>
<div class="photo-stack">
<div class="photo-cell"><img src="${pSrc(photos[1])}" /></div>
<div class="photo-cell"><img src="${pSrc(photos[2])}" /></div>
</div>
</div>`
}
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
? `<div class="day-header">Day ${di + 1} · ${fmtDate(date)}</div>`
: ''
// Photo block
const photoHtml = renderPhotoBlock(photos)
// Pros/cons
const prosconsHtml = renderProscons(entry)
// Story (markdown)
const storyHtml = entry.story ? `<div class="entry-story">${md(entry.story)}</div>` : ''
entryPages.push(`
<div class="entry-page">
${dayHeaderHtml}
${photoHtml}
<div class="entry-content">
${meta ? `<div class="entry-meta">${esc(meta)}</div>` : ''}
${entry.title ? `<h2 class="entry-title">${esc(entry.title)}</h2>` : ''}
${storyHtml}
${prosconsHtml}
</div>
</div>
`)
})
})
const totalPages = pageNum + 1 // +1 for closing page
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<base href="${window.location.origin}/">
<title>${esc(journey.title)} — Journey Book</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', -apple-system, sans-serif; color: #1A1A1A; font-size: 11pt; line-height: 1.55; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
img { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
@page { size: A4 landscape; margin: 0; }
/* ── Cover ─── */
.cover-page {
width: 100%; height: 100vh; position: relative; overflow: hidden;
background: #0a0a0f; color: white; display: flex; align-items: center; justify-content: center;
page-break-after: always;
}
.cover-bg { position: absolute; inset: 0; background-size: cover; background-position: center; }
.cover-dim { position: absolute; inset: 0; background: rgba(0,0,0,0.5); }
.cover-mesh { position: absolute; inset: 0; background: radial-gradient(circle at 20% 30%, rgba(99,102,241,0.2), transparent 50%), radial-gradient(circle at 80% 70%, rgba(236,72,153,0.15), transparent 50%); }
.cover-content { position: relative; z-index: 2; text-align: center; padding: 60pt; }
.cover-label { font-size: 9pt; font-weight: 700; letter-spacing: 6pt; text-transform: uppercase; opacity: 0.35; margin-bottom: 24pt; }
.cover-content h1 { font-size: 56pt; font-weight: 800; letter-spacing: -0.03em; line-height: 0.9; margin-bottom: 10pt; }
.cover-content .sub { font-size: 14pt; font-weight: 400; opacity: 0.7; margin-bottom: 36pt; }
.cover-stats { display: flex; gap: 48pt; justify-content: center; }
.cover-stat-val { font-size: 32pt; font-weight: 800; letter-spacing: -0.02em; }
.cover-stat-label { font-size: 10pt; text-transform: uppercase; letter-spacing: 2pt; opacity: 0.4; margin-top: 3pt; }
.cover-footer { position: absolute; bottom: 20pt; left: 0; right: 0; text-align: center; font-size: 9pt; opacity: 0.2; letter-spacing: 3pt; text-transform: uppercase; }
/* ── TOC ─── */
.toc-page {
width: 100%; height: 100vh; padding: 48pt 64pt; display: flex; flex-direction: column;
background: white; page-break-after: always;
}
.toc-top-label { font-size: 9pt; font-weight: 700; letter-spacing: 5pt; text-transform: uppercase; color: #94a3b8; margin-bottom: 16pt; }
.toc-title-block h2 { font-size: 36pt; font-weight: 800; letter-spacing: -1pt; color: #0a0a0f; margin-bottom: 4pt; }
.toc-title-block .sub { font-size: 13pt; color: #71717a; margin-bottom: 24pt; }
.toc-divider { height: 1pt; background: #e4e4e7; margin: 16pt 0; }
.toc-body { flex: 1; columns: 2; column-gap: 40pt; }
.toc-day { break-inside: avoid; margin-bottom: 14pt; }
.toc-day-label { font-size: 9pt; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; color: #71717a; margin-bottom: 4pt; }
.toc-entry { display: flex; align-items: baseline; gap: 4pt; font-size: 11pt; color: #3f3f46; margin-bottom: 2pt; }
.toc-entry .toc-title { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200pt; }
.toc-entry .toc-dots { flex: 1; border-bottom: 1pt dotted #d4d4d8; margin: 0 4pt; min-width: 20pt; }
.toc-entry .toc-page { font-size: 10pt; color: #a1a1aa; font-weight: 500; flex-shrink: 0; }
.toc-stats { display: flex; gap: 32pt; margin-top: auto; padding-top: 16pt; border-top: 1pt solid #e4e4e7; }
.toc-stat-val { font-size: 18pt; font-weight: 800; color: #0a0a0f; }
.toc-stat-label { font-size: 9pt; text-transform: uppercase; letter-spacing: 1pt; color: #94a3b8; }
/* ── Entry Page ─── */
.entry-page {
width: 100%; min-height: 100vh; padding: 56pt 48pt 48pt;
page-break-after: always;
display: flex; flex-direction: column;
}
/* Day header — inline */
.day-header {
font-size: 9pt; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase;
color: #71717a; text-align: center; margin-bottom: 16pt; position: relative;
display: flex; align-items: center; gap: 12pt;
}
.day-header::before, .day-header::after { content: ''; flex: 1; height: 0.5pt; background: #d4d4d8; }
/* Photos */
.entry-photo-single { border-radius: 8pt; overflow: hidden; margin-bottom: 16pt; height: 55vh; }
.entry-photo-single img { width: 100%; height: 100%; object-fit: cover; display: block; }
.entry-photo-duo { display: grid; grid-template-columns: 1fr 1fr; gap: 6pt; border-radius: 8pt; overflow: hidden; margin-bottom: 16pt; height: 45vh; }
.entry-photo-trio { display: grid; grid-template-columns: 3fr 2fr; gap: 6pt; border-radius: 8pt; overflow: hidden; margin-bottom: 16pt; height: 50vh; }
.photo-cell { overflow: hidden; }
.photo-cell img { width: 100%; height: 100%; object-fit: cover; display: block; }
.photo-hero { overflow: hidden; }
.photo-hero img { width: 100%; height: 100%; object-fit: cover; display: block; }
.photo-stack { display: flex; flex-direction: column; gap: 6pt; }
.photo-stack .photo-cell { flex: 1; }
/* Entry content */
.entry-content { flex: 1; }
.entry-meta { font-size: 10pt; letter-spacing: 0.04em; text-transform: uppercase; color: #71717a; font-weight: 500; margin-bottom: 6pt; }
h2.entry-title { font-size: 28pt; font-weight: 700; letter-spacing: -0.02em; line-height: 1.1; margin: 0 0 10pt; color: #0a0a0f; }
.entry-story { font-size: 11pt; line-height: 1.65; color: #3f3f46; }
.entry-story p { margin: 0 0 8pt; }
.entry-story strong { font-weight: 600; color: #0a0a0f; }
.entry-story em { font-style: italic; }
.entry-story blockquote { margin: 12pt 0; padding-left: 12pt; border-left: 2pt solid #d4d4d8; font-style: italic; color: #52525b; }
.entry-story ul, .entry-story ol { margin: 8pt 0; padding-left: 16pt; }
.entry-story li { margin-bottom: 4pt; }
.entry-story a { color: #2563eb; text-decoration: none; }
/* Verdict */
.verdict-wrap { break-inside: avoid; padding-top: 14pt; }
.verdict-row { display: flex; gap: 10pt; }
.verdict-card { flex: 1; padding: 10pt 12pt; border-radius: 6pt; font-size: 9.5pt; }
.verdict-card.pros { background: #f0fdf4; border: 0.5pt solid #bbf7d0; }
.verdict-card.cons { background: #fef2f2; border: 0.5pt solid #fecaca; }
.verdict-label { font-size: 8pt; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 6pt; }
.verdict-card.pros .verdict-label { color: #15803d; }
.verdict-card.cons .verdict-label { color: #b91c1c; }
.verdict-card ul { margin: 0; padding: 0; list-style: none; }
.verdict-card li { padding: 2pt 0; position: relative; padding-left: 10pt; }
.verdict-card li::before { content: '•'; position: absolute; left: 0; }
.verdict-card.pros li { color: #14532d; }
.verdict-card.pros li::before { color: #22c55e; }
.verdict-card.cons li { color: #7f1d1d; }
.verdict-card.cons li::before { color: #ef4444; }
/* ── Closing ─── */
.closing-page {
width: 100%; height: 100vh; display: flex; align-items: center; justify-content: center;
background: #0a0a0f; color: white; text-align: center; page-break-after: auto;
}
.closing-title { font-size: 32pt; font-weight: 300; letter-spacing: -1pt; opacity: 0.6; margin-bottom: 8pt; }
.closing-sub { font-size: 10pt; opacity: 0.25; letter-spacing: 3pt; text-transform: uppercase; }
/* ── Print ─── */
@media print {
.print-bar { display: none !important; }
body { margin: 0; }
.entry-page { orphans: 3; widows: 3; }
h2.entry-title { page-break-after: avoid; }
.verdict-row { page-break-inside: avoid; }
.entry-photo-single, .entry-photo-duo, .entry-photo-trio { page-break-after: avoid; }
}
.print-bar {
position: fixed; top: 0; left: 0; right: 0; z-index: 9999;
background: rgba(15,23,42,0.95); backdrop-filter: blur(12px);
padding: 12px 24px; display: flex; align-items: center; justify-content: center; gap: 12px;
}
.print-bar button { padding: 8px 24px; border-radius: 10px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; border: none; }
.print-bar .btn-save { background: white; color: #0f172a; }
.print-bar .btn-close { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7); border: 1px solid rgba(255,255,255,0.15); }
.print-bar .info { font-size: 11px; color: rgba(255,255,255,0.4); }
</style>
</head>
<body>
<div class="print-bar">
<span class="info">${esc(journey.title)} · ${totalPages} pages</span>
<button class="btn-save" onclick="window.print()">Save as PDF</button>
<button class="btn-close" onclick="window.close()">Close</button>
</div>
<!-- Page 1: Cover -->
<div class="cover-page">
${coverUrl ? `<div class="cover-bg" style="background-image:url('${coverUrl}')"></div>` : ''}
<div class="cover-dim"></div>
<div class="cover-mesh"></div>
<div class="cover-content">
<div class="cover-label">Journey Book</div>
<h1>${esc(journey.title)}</h1>
${journey.subtitle ? `<div class="sub">${esc(journey.subtitle)}</div>` : ''}
<div class="cover-stats">
<div><div class="cover-stat-val">${dates.length}</div><div class="cover-stat-label">Days</div></div>
<div><div class="cover-stat-val">${entries.length}</div><div class="cover-stat-label">Entries</div></div>
<div><div class="cover-stat-val">${allPhotos.length}</div><div class="cover-stat-label">Photos</div></div>
</div>
</div>
<div class="cover-footer">Made with TREK</div>
</div>
<!-- Entry Pages -->
${entryPages.join('\n')}
<!-- Closing Page -->
<div class="closing-page">
<div>
<div class="closing-title">The End</div>
<div class="closing-sub">Made with TREK · ${new Date().getFullYear()}</div>
</div>
</div>
</body>
</html>`
const win = window.open('', '_blank')
if (!win) return
win.document.write(html)
win.document.close()
}
@@ -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" } const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
return ( return (
<div style={{ position: 'fixed', bottom: 20, left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, zIndex: 50, ...font }}> <div className="fixed z-50 bottom-[96px] md:bottom-5" style={{ left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...font }}>
<div style={{ <div style={{
background: 'var(--bg-elevated)', background: 'var(--bg-elevated)',
backdropFilter: 'blur(40px) saturate(180%)', backdropFilter: 'blur(40px) saturate(180%)',
@@ -4,7 +4,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useRef, useMemo } from 'react' import React, { useState, useEffect, useRef, useMemo } from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2 } from 'lucide-react' import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X } from 'lucide-react'
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText } const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
import { assignmentsApi, reservationsApi } from '../../api/client' import { assignmentsApi, reservationsApi } from '../../api/client'
@@ -55,6 +55,99 @@ const TYPE_ICONS = {
car: '🚗', cruise: '🚢', event: '🎫', other: '📋', car: '🚗', cruise: '🚢', event: '🎫', other: '📋',
} }
function MobileAddPlaceButton({ dayId, places, assignments, onAssign, onAddNew }: {
dayId: number
places: Place[]
assignments: AssignmentsMap
onAssign?: (placeId: number, dayId: number) => 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 (
<div className="md:hidden" style={{ padding: '8px 12px 12px' }}>
{!open ? (
<button
onClick={e => { e.stopPropagation(); setOpen(true) }}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
padding: '10px 0', borderRadius: 12,
border: '1.5px dashed var(--border-primary)',
background: 'transparent', color: 'var(--text-muted)',
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', cursor: 'pointer',
}}
>
<Plus size={14} />
Add Place
</button>
) : (
<div style={{ borderRadius: 14, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', overflow: 'hidden' }}>
<div style={{ padding: '8px 10px', borderBottom: '1px solid var(--border-faint)', display: 'flex', gap: 6 }}>
<input
autoFocus
value={search}
onChange={e => 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)' }}
/>
<button onClick={() => { setOpen(false); setSearch('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}>
<X size={14} />
</button>
</div>
<div style={{ maxHeight: 200, overflowY: 'auto' }}>
{filtered.length === 0 && (
<div style={{ padding: '16px 12px', textAlign: 'center', fontSize: 12, color: 'var(--text-faint)' }}>
{available.length === 0 ? t('dayplan.mobile.allAssigned') : t('dayplan.mobile.noMatch')}
</div>
)}
{filtered.slice(0, 20).map(p => (
<button
key={p.id}
onClick={() => {
onAssign?.(p.id, dayId)
setOpen(false)
setSearch('')
}}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 12px', border: 'none', background: 'transparent',
cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
}}
>
<MapPin size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
</button>
))}
</div>
{onAddNew && (
<button
onClick={() => { onAddNew(); setOpen(false); setSearch('') }}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
padding: '10px 0', borderTop: '1px solid var(--border-faint)',
background: 'transparent', border: 'none', color: 'var(--text-muted)',
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', cursor: 'pointer',
}}
>
<Plus size={13} />
Create new place
</button>
)}
</div>
)}
</div>
)
}
interface DayPlanSidebarProps { interface DayPlanSidebarProps {
tripId: number tripId: number
trip: Trip trip: Trip
@@ -79,6 +172,8 @@ interface DayPlanSidebarProps {
reservations?: Reservation[] reservations?: Reservation[]
onAddReservation: () => void onAddReservation: () => void
onNavigateToFiles?: () => void onNavigateToFiles?: () => void
onAddPlace?: () => void
onAddPlaceToDay?: (placeId: number, dayId: number) => void
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
canUndo?: boolean canUndo?: boolean
@@ -95,6 +190,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace, onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
reservations = [], reservations = [],
onAddReservation, onAddReservation,
onAddPlace,
onAddPlaceToDay,
onNavigateToFiles, onNavigateToFiles,
onExpandedDaysChange, onExpandedDaysChange,
pushUndo, pushUndo,
@@ -1623,6 +1720,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</div> </div>
</div> </div>
)} )}
{/* Mobile: Add Place from list */}
<MobileAddPlaceButton
dayId={day.id}
places={places}
assignments={assignments}
onAssign={onAssignToDay}
onAddNew={onAddPlace}
/>
</div> </div>
)} )}
</div> </div>
+51 -8
View File
@@ -46,6 +46,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
const [uploadingCover, setUploadingCover] = useState(false) const [uploadingCover, setUploadingCover] = useState(false)
const [allUsers, setAllUsers] = useState<{ id: number; username: string }[]>([]) const [allUsers, setAllUsers] = useState<{ id: number; username: string }[]>([])
const [selectedMembers, setSelectedMembers] = useState<number[]>([]) const [selectedMembers, setSelectedMembers] = useState<number[]>([])
const [existingMembers, setExistingMembers] = useState<{ id: number; username: string }[]>([])
const [memberSelectValue, setMemberSelectValue] = useState('') const [memberSelectValue, setMemberSelectValue] = useState('')
useEffect(() => { 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) if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
}).catch(() => {}) }).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]) }, [trip, isOpen])
@@ -365,12 +369,38 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
</div> </div>
)} )}
{/* Members — only for new trips */} {/* Members */}
{!isEditing && allUsers.filter(u => u.id !== currentUser?.id).length > 0 && ( {allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1.5"> <label className="block text-sm font-medium text-slate-700 mb-1.5">
<UserPlus className="inline w-4 h-4 mr-1" />{t('dashboard.addMembers')} <UserPlus className="inline w-4 h-4 mr-1" />{isEditing ? t('dashboard.addMembers') : t('dashboard.addMembers')}
</label> </label>
{/* Existing members (editing mode) */}
{isEditing && existingMembers.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
{existingMembers.map(m => (
<span key={m.id}
onClick={async () => {
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 && <X size={11} style={{ color: 'var(--text-faint)' }} />}
</span>
))}
</div>
)}
{/* Newly selected members (both modes) */}
{selectedMembers.length > 0 && ( {selectedMembers.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
{selectedMembers.map(uid => { {selectedMembers.map(uid => {
@@ -393,11 +423,24 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<CustomSelect <CustomSelect
value={memberSelectValue} value={memberSelectValue}
onChange={value => { onChange={async value => {
if (value) { setSelectedMembers(prev => prev.includes(Number(value)) ? prev : [...prev, Number(value)]); setMemberSelectValue('') } 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')} 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 searchable
size="sm" size="sm"
/> />
+27 -1
View File
@@ -1,7 +1,8 @@
import { useMemo, useState, useCallback } from 'react' import { useMemo, useState, useCallback, useEffect } from 'react'
import { useVacayStore } from '../../store/vacayStore' import { useVacayStore } from '../../store/vacayStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { isWeekend } from './holidays' import { isWeekend } from './holidays'
import { tripsApi } from '../../api/client'
import VacayMonthCard from './VacayMonthCard' import VacayMonthCard from './VacayMonthCard'
import { Building2, MousePointer2 } from 'lucide-react' import { Building2, MousePointer2 } from 'lucide-react'
@@ -9,6 +10,30 @@ export default function VacayCalendar() {
const { t } = useTranslation() const { t } = useTranslation()
const { selectedYear, selectedUserId, entries, companyHolidays, toggleEntry, toggleCompanyHoliday, plan, users, holidays } = useVacayStore() const { selectedYear, selectedUserId, entries, companyHolidays, toggleEntry, toggleCompanyHoliday, plan, users, holidays } = useVacayStore()
const [companyMode, setCompanyMode] = useState(false) const [companyMode, setCompanyMode] = useState(false)
const [tripDates, setTripDates] = useState<Set<string>>(new Set())
useEffect(() => {
let cancelled = false
;(async () => {
try {
const data = await tripsApi.list()
const dates = new Set<string>()
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 companyHolidaySet = useMemo(() => {
const s = new Set() const s = new Set()
@@ -59,6 +84,7 @@ export default function VacayCalendar() {
companyMode={companyMode} companyMode={companyMode}
blockWeekends={blockWeekends} blockWeekends={blockWeekends}
weekendDays={weekendDays} weekendDays={weekendDays}
tripDates={tripDates}
/> />
))} ))}
</div> </div>
@@ -23,11 +23,12 @@ interface VacayMonthCardProps {
companyMode: boolean companyMode: boolean
blockWeekends: boolean blockWeekends: boolean
weekendDays?: number[] weekendDays?: number[]
tripDates?: Set<string>
} }
export default function VacayMonthCard({ export default function VacayMonthCard({
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap, year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
onCellClick, companyMode, blockWeekends, weekendDays = [0, 6] onCellClick, companyMode, blockWeekends, weekendDays = [0, 6], tripDates
}: VacayMonthCardProps) { }: VacayMonthCardProps) {
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
@@ -122,6 +123,10 @@ export default function VacayMonthCard({
</div> </div>
)} )}
{tripDates?.has(dateStr) && (
<span className="absolute top-[3px] right-[3px] w-[5px] h-[5px] rounded-full z-[2]" style={{ background: '#3b82f6' }} />
)}
<span className="relative z-[1] text-[11px] font-medium" style={{ <span className="relative z-[1] text-[11px] font-medium" style={{
color: holiday ? holiday.color : weekend ? 'var(--text-faint)' : 'var(--text-primary)', color: holiday ? holiday.color : weekend ? 'var(--text-faint)' : 'var(--text-primary)',
fontWeight: dayEntries.length > 0 ? 700 : 500, fontWeight: dayEntries.length > 0 ? 700 : 500,
+1 -1
View File
@@ -61,7 +61,7 @@ export default function Modal({
<div <div
className={` className={`
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md} rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
flex flex-col max-h-[calc(100vh-90px)] flex flex-col max-h-[calc(100vh-180px)] md:max-h-[calc(100vh-90px)]
animate-in fade-in zoom-in-95 duration-200 animate-in fade-in zoom-in-95 duration-200
`} `}
style={{ style={{
+233
View File
@@ -1685,6 +1685,239 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'notif.generic.text': 'Você tem uma nova notificação', 'notif.generic.text': 'Você tem uma nova notificação',
'notif.dev.unknown_event.title': '[DEV] Evento desconhecido', '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', '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 <strong>{title}</strong> 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 export default br
+233
View File
@@ -1690,6 +1690,239 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'notif.generic.text': 'Máte nové oznámení', 'notif.generic.text': 'Máte nové oznámení',
'notif.dev.unknown_event.title': '[DEV] Neznámá událost', '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', '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': 'Odbavte 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 <strong>{title}</strong> 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 export default cs
+231 -6
View File
@@ -26,6 +26,9 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'E-Mail', 'common.email': 'E-Mail',
'common.password': 'Passwort', 'common.password': 'Passwort',
'common.saving': 'Speichern...', 'common.saving': 'Speichern...',
'common.justNow': 'gerade eben',
'common.hoursAgo': 'vor {count}h',
'common.daysAgo': 'vor {count}T',
'common.saved': 'Gespeichert', 'common.saved': 'Gespeichert',
'trips.reminder': 'Erinnerung', 'trips.reminder': 'Erinnerung',
'trips.reminderNone': 'Keine', 'trips.reminderNone': 'Keine',
@@ -179,9 +182,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.none': 'Deaktiviert', 'admin.notifications.none': 'Deaktiviert',
'admin.notifications.email': 'E-Mail (SMTP)', 'admin.notifications.email': 'E-Mail (SMTP)',
'admin.notifications.webhook': 'Webhook', '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.save': 'Benachrichtigungseinstellungen speichern',
'admin.notifications.saved': 'Benachrichtigungseinstellungen gespeichert', 'admin.notifications.saved': 'Benachrichtigungseinstellungen gespeichert',
'admin.notifications.testWebhook': 'Test-Webhook senden', 'admin.notifications.testWebhook': 'Test-Webhook senden',
@@ -1110,7 +1110,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'packing.saveAsTemplate': 'Als Vorlage speichern', 'packing.saveAsTemplate': 'Als Vorlage speichern',
'packing.templateName': 'Vorlagenname', 'packing.templateName': 'Vorlagenname',
'packing.templateSaved': 'Packliste als Vorlage gespeichert', 'packing.templateSaved': 'Packliste als Vorlage gespeichert',
'packing.assignUser': 'Person zuweisen',
'packing.bags': 'Gepäck', 'packing.bags': 'Gepäck',
'packing.noBag': 'Nicht zugeordnet', 'packing.noBag': 'Nicht zugeordnet',
'packing.totalWeight': 'Gesamtgewicht', 'packing.totalWeight': 'Gesamtgewicht',
@@ -1394,8 +1393,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'memories.reviewTitle': 'Deine Fotos prüfen', 'memories.reviewTitle': 'Deine Fotos prüfen',
'memories.reviewHint': 'Klicke auf Fotos, um sie vom Teilen auszuschließen.', 'memories.reviewHint': 'Klicke auf Fotos, um sie vom Teilen auszuschließen.',
'memories.shareCount': '{count} Fotos teilen', 'memories.shareCount': '{count} Fotos teilen',
'memories.immichUrl': 'Immich Server URL',
'memories.immichApiKey': 'API-Schlüssel',
'memories.testConnection': 'Verbindung testen', 'memories.testConnection': 'Verbindung testen',
'memories.testFirst': 'Verbindung zuerst testen', 'memories.testFirst': 'Verbindung zuerst testen',
'memories.connected': 'Verbunden', 'memories.connected': 'Verbunden',
@@ -1692,6 +1689,234 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'notif.generic.text': 'Du hast eine neue Benachrichtigung', 'notif.generic.text': 'Du hast eine neue Benachrichtigung',
'notif.dev.unknown_event.title': '[DEV] Unbekanntes Ereignis', 'notif.dev.unknown_event.title': '[DEV] Unbekanntes Ereignis',
'notif.dev.unknown_event.text': 'Ereignistyp "{event}" ist nicht in EVENT_NOTIFICATION_CONFIG registriert', '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 <strong>{title}</strong> 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 export default de
+256
View File
@@ -26,6 +26,9 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'Email', 'common.email': 'Email',
'common.password': 'Password', 'common.password': 'Password',
'common.saving': 'Saving...', 'common.saving': 'Saving...',
'common.justNow': 'just now',
'common.hoursAgo': '{count}h ago',
'common.daysAgo': '{count}d ago',
'common.saved': 'Saved', 'common.saved': 'Saved',
'trips.reminder': 'Reminder', 'trips.reminder': 'Reminder',
'trips.reminderNone': 'None', 'trips.reminderNone': 'None',
@@ -1698,6 +1701,259 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'notif.generic.text': 'You have a new notification', 'notif.generic.text': 'You have a new notification',
'notif.dev.unknown_event.title': '[DEV] Unknown Event', 'notif.dev.unknown_event.title': '[DEV] Unknown Event',
'notif.dev.unknown_event.text': 'Event type "{event}" is not registered in EVENT_NOTIFICATION_CONFIG', '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 <strong>{title}</strong> 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 export default en
+233
View File
@@ -1692,6 +1692,239 @@ const es: Record<string, string> = {
'notif.generic.text': 'Tienes una nueva notificación', 'notif.generic.text': 'Tienes una nueva notificación',
'notif.dev.unknown_event.title': '[DEV] Evento desconocido', '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', '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 <strong>{title}</strong> 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 export default es
+233
View File
@@ -1686,6 +1686,239 @@ const fr: Record<string, string> = {
'notif.generic.text': 'Vous avez une nouvelle notification', 'notif.generic.text': 'Vous avez une nouvelle notification',
'notif.dev.unknown_event.title': '[DEV] Événement inconnu', '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', '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 <strong>{title}</strong> 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 export default fr
+233
View File
@@ -1687,6 +1687,239 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'notif.generic.text': 'Új értesítésed érkezett', 'notif.generic.text': 'Új értesítésed érkezett',
'notif.dev.unknown_event.title': '[DEV] Ismeretlen esemény', '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', '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) <strong>{title}</strong> ú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 export default hu
+233
View File
@@ -1687,6 +1687,239 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'notif.generic.text': 'Hai una nuova notifica', 'notif.generic.text': 'Hai una nuova notifica',
'notif.dev.unknown_event.title': '[DEV] Evento sconosciuto', 'notif.dev.unknown_event.title': '[DEV] Evento sconosciuto',
'notif.dev.unknown_event.text': 'Il tipo di evento "{event}" non è registrato in EVENT_NOTIFICATION_CONFIG', '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 <strong>{title}</strong> 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 export default it
+233
View File
@@ -1686,6 +1686,239 @@ const nl: Record<string, string> = {
'notif.generic.text': 'Je hebt een nieuwe melding', 'notif.generic.text': 'Je hebt een nieuwe melding',
'notif.dev.unknown_event.title': '[DEV] Onbekende gebeurtenis', 'notif.dev.unknown_event.title': '[DEV] Onbekende gebeurtenis',
'notif.dev.unknown_event.text': 'Gebeurtenistype "{event}" is niet geregistreerd in EVENT_NOTIFICATION_CONFIG', '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 <strong>{title}</strong> 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 export default nl
+233
View File
@@ -1679,6 +1679,239 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'notif.generic.text': 'Masz nowe powiadomienie', 'notif.generic.text': 'Masz nowe powiadomienie',
'notif.dev.unknown_event.title': '[DEV] Nieznane zdarzenie', 'notif.dev.unknown_event.title': '[DEV] Nieznane zdarzenie',
'notif.dev.unknown_event.text': 'Typ zdarzenia "{event}" nie jest zarejestrowany w EVENT_NOTIFICATION_CONFIG', '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ń <strong>{title}</strong> 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 export default pl
+233
View File
@@ -1686,6 +1686,239 @@ const ru: Record<string, string> = {
'notif.generic.text': 'У вас новое уведомление', 'notif.generic.text': 'У вас новое уведомление',
'notif.dev.unknown_event.title': '[DEV] Неизвестное событие', 'notif.dev.unknown_event.title': '[DEV] Неизвестное событие',
'notif.dev.unknown_event.text': 'Тип события "{event}" не зарегистрирован в EVENT_NOTIFICATION_CONFIG', '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': 'Превратите <strong>{title}</strong> в путешествие',
'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 export default ru
+233
View File
@@ -1686,6 +1686,239 @@ const zh: Record<string, string> = {
'notif.generic.text': '您有一条新通知', 'notif.generic.text': '您有一条新通知',
'notif.dev.unknown_event.title': '[DEV] 未知事件', 'notif.dev.unknown_event.title': '[DEV] 未知事件',
'notif.dev.unknown_event.text': '事件类型 "{event}" 未在 EVENT_NOTIFICATION_CONFIG 中注册', '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': '将 <strong>{title}</strong> 变成一段旅程',
'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 export default zh
+372
View File
@@ -1541,6 +1541,378 @@ const zhTw: Record<string, string> = {
'notifications.test.adminText': '{actor} 向所有管理員傳送了測試通知。', 'notifications.test.adminText': '{actor} 向所有管理員傳送了測試通知。',
'notifications.test.tripTitle': '{actor} 在您的行程中發帖', 'notifications.test.tripTitle': '{actor} 在您的行程中發帖',
'notifications.test.tripText': '行程"{trip}"的測試通知。', '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': '將 <strong>{title}</strong> 變成一段旅程',
'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 export default zhTw
+49 -11
View File
@@ -140,7 +140,7 @@ html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color
/* ── Design tokens ─────────────────────────────── */ /* ── Design tokens ─────────────────────────────── */
:root { :root {
--safe-top: env(safe-area-inset-top, 0px); --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; --font-system: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
--sp-1: 4px; --sp-1: 4px;
--sp-2: 8px; --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-track: #f1f5f9;
--scrollbar-thumb: #d1d5db; --scrollbar-thumb: #d1d5db;
--scrollbar-hover: #9ca3af; --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 { .dark {
@@ -202,6 +220,20 @@ html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color
--scrollbar-track: #131316; --scrollbar-track: #131316;
--scrollbar-thumb: #3f3f46; --scrollbar-thumb: #3f3f46;
--scrollbar-hover: #52525b; --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 { body {
@@ -267,22 +299,23 @@ body {
/* ── iOS-style map tooltip ─────────────────────── */ /* ── iOS-style map tooltip ─────────────────────── */
.leaflet-tooltip.map-tooltip { .leaflet-tooltip.map-tooltip {
background: var(--tooltip-bg); background: rgba(9, 9, 11, 0.85);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
border: none; border: none;
border-radius: var(--radius-md); border-radius: 8px;
box-shadow: var(--shadow-elevated); box-shadow: 0 4px 12px rgba(0,0,0,0.2);
padding: 6px 10px; padding: 5px 10px;
font-family: var(--font-system); font-family: var(--font-system);
font-size: 11px;
font-weight: 500;
pointer-events: none; pointer-events: none;
color: var(--text-primary); color: #fff;
} }
.leaflet-tooltip.map-tooltip::before { .leaflet-tooltip.map-tooltip::before,
border-right-color: var(--tooltip-bg); .leaflet-tooltip-left.map-tooltip::before,
} .leaflet-tooltip-top.map-tooltip::before {
.leaflet-tooltip-left.map-tooltip::before { display: none;
border-left-color: var(--tooltip-bg);
} }
/* Scrollbalken */ /* Scrollbalken */
@@ -416,6 +449,11 @@ img[alt="TREK"] {
} }
/* Toast-Animationen */ /* Toast-Animationen */
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
@keyframes slide-in-right { @keyframes slide-in-right {
from { transform: translateX(100%); opacity: 0; } from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; } to { transform: translateX(0); opacity: 1; }
+3 -3
View File
@@ -756,7 +756,7 @@ export default function AtlasPage(): React.ReactElement {
return ( return (
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}> <div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
<Navbar /> <Navbar />
<div style={{ position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, bottom: 0 }}> <div style={{ position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, bottom: 'env(safe-area-inset-bottom, 0px)' }}>
{/* Map */} {/* Map */}
<div ref={mapRef} style={{ position: 'absolute', inset: 0, zIndex: 1, background: dark ? '#1a1a2e' : '#f0f0f0' }} /> <div ref={mapRef} style={{ position: 'absolute', inset: 0, zIndex: 1, background: dark ? '#1a1a2e' : '#f0f0f0' }} />
@@ -773,7 +773,7 @@ export default function AtlasPage(): React.ReactElement {
}} /> }} />
<div <div
className="absolute z-20 flex justify-center" className="absolute z-20 flex justify-center"
style={{ top: 14, left: 0, right: 0, pointerEvents: 'none' }} style={{ top: 'calc(env(safe-area-inset-top, 0px) + 14px)', left: 0, right: 0, pointerEvents: 'none' }}
> >
<div style={{ width: 'min(520px, calc(100vw - 28px))', pointerEvents: 'auto' }}> <div style={{ width: 'min(520px, calc(100vw - 28px))', pointerEvents: 'auto' }}>
<div style={{ <div style={{
@@ -896,7 +896,7 @@ export default function AtlasPage(): React.ReactElement {
</div> </div>
{/* Mobile: Bottom bar */} {/* Mobile: Bottom bar */}
<div className="md:hidden absolute bottom-3 left-0 right-0 z-10 flex justify-center" style={{ touchAction: 'manipulation' }}> <div className="md:hidden absolute left-0 right-0 z-10 flex justify-center" style={{ bottom: 'calc(84px + env(safe-area-inset-bottom, 0px) + 8px)', touchAction: 'manipulation' }}>
<div className="flex items-center gap-4 px-5 py-4 rounded-2xl" <div className="flex items-center gap-4 px-5 py-4 rounded-2xl"
style={{ background: dark ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.5)', backdropFilter: 'blur(16px)' }}> style={{ background: dark ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.5)', backdropFilter: 'blur(16px)' }}>
{/* Countries highlighted */} {/* Countries highlighted */}
+511 -187
View File
@@ -15,7 +15,7 @@ import { useToast } from '../components/shared/Toast'
import { import {
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp, Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft, Users, Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft, Users,
LayoutGrid, List, Copy, LayoutGrid, List, Copy, Bell,
} from 'lucide-react' } from 'lucide-react'
import { useCanDo } from '../store/permissionsStore' import { useCanDo } from '../store/permissionsStore'
@@ -151,180 +151,312 @@ interface TripCardProps {
dark?: boolean 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 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 const badgeText = isLive ? t('dashboard.mobile.liveNow')
? `url(${trip.cover_image}) center/cover no-repeat` : status === 'today' ? t('dashboard.mobile.startsToday')
: tripGradient(trip.id) : status === 'tomorrow' ? t('dashboard.mobile.tomorrow')
: status === 'future' ? t('dashboard.status.daysLeft', { count: daysUntil(trip.start_date) })
return ( : status === 'past' ? t('dashboard.mobile.completed')
<LiquidGlass dark={dark} style={{ marginBottom: 32, borderRadius: 20, boxShadow: '0 8px 40px rgba(0,0,0,0.13)', cursor: 'pointer' }} : null
onClick={() => onClick(trip)}>
{/* Cover / Background */}
<div style={{ height: 300, background: coverBg, position: 'relative' }}>
<div style={{
position: 'absolute', inset: 0,
background: 'linear-gradient(to top, rgba(0,0,0,0.78) 0%, rgba(0,0,0,0.25) 50%, rgba(0,0,0,0.1) 100%)',
}} />
{/* Badges top-left */}
<div style={{ position: 'absolute', top: 16, left: 16, display: 'flex', gap: 8 }}>
{status && (
<span style={{
background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(8px)',
color: 'white', fontSize: 12, fontWeight: 700,
padding: '5px 12px', borderRadius: 99, border: '1px solid rgba(255,255,255,0.25)',
display: 'flex', alignItems: 'center', gap: 6,
}}>
{status === 'ongoing' && (
<span style={{ width: 7, height: 7, borderRadius: '50%', background: '#ef4444', animation: 'blink 1s ease-in-out infinite', display: 'inline-block', flexShrink: 0 }} />
)}
{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')}
</span>
)}
</div>
{/* Top-right actions */}
{(onEdit || onCopy || onArchive || onDelete) && (
<div style={{ position: 'absolute', top: 16, right: 16, display: 'flex', gap: 6 }}
onClick={e => e.stopPropagation()}>
{onEdit && <IconBtn onClick={() => onEdit(trip)} title={t('common.edit')}><Edit2 size={14} /></IconBtn>}
{onCopy && <IconBtn onClick={() => onCopy(trip)} title={t('dashboard.copyTrip')}><Copy size={14} /></IconBtn>}
{onArchive && <IconBtn onClick={() => onArchive(trip.id)} title={t('dashboard.archive')}><Archive size={14} /></IconBtn>}
{onDelete && <IconBtn onClick={() => onDelete(trip)} title={t('common.delete')} danger><Trash2 size={14} /></IconBtn>}
</div>
)}
{/* Bottom content */}
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '20px 24px' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'rgba(255,255,255,0.65)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 6 }}>
{trip.is_owner ? t('dashboard.nextTrip') : t('dashboard.sharedBy', { name: trip.owner_username })}
</div>
<h2 style={{ margin: 0, fontSize: 26, fontWeight: 800, color: 'white', lineHeight: 1.2, textShadow: '0 1px 4px rgba(0,0,0,0.3)' }}>
{trip.title}
</h2>
{trip.description && (
<p style={{ margin: '6px 0 0', fontSize: 13.5, color: 'rgba(255,255,255,0.75)', lineHeight: 1.4, overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
{trip.description}
</p>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginTop: 12 }}>
{trip.start_date && (
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
<Calendar size={13} />
{formatDateShort(trip.start_date, locale)}
{trip.end_date && <> {formatDateShort(trip.end_date, locale)}</>}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
<Clock size={13} /> {trip.day_count || 0} {t('dashboard.days')}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
<MapPin size={13} /> {trip.place_count || 0} {t('dashboard.places')}
</div>
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
<Users size={13} /> {trip.shared_count+1 || 0} {t('dashboard.members')}
</div>
</div>
</div>
</div>
</LiquidGlass>
)
}
// ── Regular Trip Card ────────────────────────────────────────────────────────
function TripCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): 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)
return ( return (
<div <div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={() => onClick(trip)} onClick={() => onClick(trip)}
style={{ className="group relative rounded-3xl overflow-hidden cursor-pointer mb-8"
background: hovered ? 'var(--bg-tertiary)' : 'var(--bg-card)', borderRadius: 16, overflow: 'hidden', cursor: 'pointer', style={{ minHeight: 340, boxShadow: '0 8px 40px rgba(0,0,0,0.13)' }}
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',
}}
> >
{/* Image area */} {/* Background */}
<div style={{ height: 120, background: coverBg, position: 'relative', overflow: 'hidden' }}> <div className="absolute inset-0" style={{
{trip.cover_image && <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(0,0,0,0.35) 0%, transparent 60%)' }} />} background: trip.cover_image ? undefined : tripGradient(trip.id),
}}>
{/* Status badge */} {trip.cover_image && (
{status && ( <>
<div style={{ position: 'absolute', top: 8, left: 8 }}> <img src={trip.cover_image} className="w-full h-full object-cover" alt="" />
<span style={{ <div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.6) 100%)' }} />
fontSize: 10.5, fontWeight: 700, padding: '2px 8px', borderRadius: 99, </>
background: 'rgba(0,0,0,0.4)', color: 'white', backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', gap: 5,
}}>
{status === 'ongoing' && (
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#ef4444', animation: 'blink 1s ease-in-out infinite', display: 'inline-block', flexShrink: 0 }} />
)}
{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')}
</span>
</div>
)} )}
</div> </div>
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, transparent 0%, transparent 40%, rgba(0,0,0,0.5) 100%)' }} />
{/* Content */} {/* Content */}
<div style={{ padding: '12px 14px 14px' }}> <div className="relative p-6 flex flex-col text-white z-[2]" style={{ minHeight: 340 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, overflow: 'hidden', marginBottom: 3 }}> {/* Top: badge + actions */}
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <div className="flex items-center justify-between mb-5">
{trip.title} {badgeText ? (
</span> <span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black/40 backdrop-blur-sm border border-white/15 rounded-full text-[10px] font-bold uppercase tracking-[0.1em]">
{isLive ? (
<span className="w-1.5 h-1.5 rounded-full bg-red-500 shadow-[0_0_6px_rgba(239,68,68,0.8)] animate-pulse" />
) : (
<Clock size={10} />
)}
{badgeText}
</span>
) : <span />}
<div className="flex gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity" onClick={e => e.stopPropagation()}>
{onEdit && <button onClick={() => onEdit(trip)} className="w-[34px] h-[34px] rounded-[10px] bg-white/12 backdrop-blur-sm border border-white/15 flex items-center justify-center text-white hover:bg-white/20 transition-colors"><Edit2 size={14} /></button>}
{onCopy && <button onClick={() => onCopy(trip)} className="w-[34px] h-[34px] rounded-[10px] bg-white/12 backdrop-blur-sm border border-white/15 flex items-center justify-center text-white hover:bg-white/20 transition-colors"><Copy size={14} /></button>}
{onArchive && <button onClick={() => onArchive(trip.id)} className="w-[34px] h-[34px] rounded-[10px] bg-white/12 backdrop-blur-sm border border-white/15 flex items-center justify-center text-white hover:bg-white/20 transition-colors"><Archive size={14} /></button>}
{onDelete && <button onClick={() => onDelete(trip)} className="w-[34px] h-[34px] rounded-[10px] bg-white/12 backdrop-blur-sm border border-white/15 flex items-center justify-center text-red-300 hover:bg-red-500/20 transition-colors"><Trash2 size={14} /></button>}
</div>
</div>
{/* Title area — pushed to bottom */}
<div className="flex-1 flex flex-col justify-end mb-4">
{!trip.is_owner && ( {!trip.is_owner && (
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 99, whiteSpace: 'nowrap', flexShrink: 0 }}> <span className="inline-flex items-center gap-1 self-start px-2 py-0.5 bg-white/15 backdrop-blur-sm border border-white/15 rounded-full text-[9px] font-semibold uppercase tracking-[0.06em] mb-2">
{t('dashboard.shared')} <Users size={9} /> {t('dashboard.sharedBy', { name: trip.owner_username })}
</span> </span>
)} )}
</div> <h2 className="text-[32px] font-extrabold tracking-[-0.03em] leading-[0.95] mb-1.5">{trip.title}</h2>
{trip.description && ( <p className="text-[12px] opacity-80 font-medium">
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: '0 0 8px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> {formatDateShort(trip.start_date, locale)} {formatDateShort(trip.end_date, locale)}
{trip.description} {isLive && <> · {t('journey.pdf.day')} {currentDay} / {totalDays}</>}
</p> </p>
)} </div>
{(trip.start_date || trip.end_date) && ( {/* Progress bar — only for live trips */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', marginBottom: 10 }}> {isLive && (
<Calendar size={11} style={{ flexShrink: 0 }} /> <div className="mb-4">
{trip.start_date && trip.end_date <div className="flex justify-between text-[11px] font-semibold mb-1.5">
? `${formatDateShort(trip.start_date, locale)}${formatDateShort(trip.end_date, locale)}` <span className="opacity-85">{t('dashboard.mobile.tripProgress')}</span>
: formatDate(trip.start_date || trip.end_date, locale)} <span className="opacity-70">{t('dashboard.mobile.daysLeft', { count: daysLeft })}</span>
</div>
<div className="h-1.5 bg-white/15 rounded-full overflow-hidden">
<div className="h-full bg-white rounded-full relative" style={{ width: `${progress}%` }}>
<span className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow-[0_0_12px_rgba(255,255,255,0.9)]" />
</div>
</div>
</div> </div>
)} )}
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}> {/* Stats */}
<Stat label={t('dashboard.days')} value={trip.day_count || 0} /> <div className="grid grid-cols-4 gap-2.5 p-3.5 bg-black/25 backdrop-blur-sm border border-white/10 rounded-2xl">
<Stat label={t('dashboard.places')} value={trip.place_count || 0} /> {trip.start_date && !isLive && (
<Stat label={t('dashboard.members')} value={trip.shared_count+1 || 0} /> <div className="text-center">
<p className="text-[18px] font-extrabold tracking-[-0.02em] leading-none">{formatDateShort(trip.start_date, locale)}</p>
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.starts')}</p>
</div>
)}
{isLive && (
<div className="text-center">
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none">{totalDays}</p>
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.duration')}</p>
</div>
)}
<div className="text-center">
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none">{trip.place_count || 0}</p>
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.places')}</p>
</div>
<div className="text-center">
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none">{trip.shared_count || 0}</p>
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.buddies')}</p>
</div>
{!isLive && (
<div className="text-center">
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none">{trip.day_count || totalDays}</p>
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.days')}</p>
</div>
)}
</div>
</div>
</div>
)
}
// ── Mobile Trip Card (upcoming style) ────────────────────────────────────────
function MobileTripCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): 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 (
<div
onClick={() => 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 */}
<div className="relative h-[120px] overflow-hidden" style={{ background: trip.cover_image ? undefined : tripGradient(trip.id) }}>
{trip.cover_image && (
<img src={trip.cover_image} className="absolute inset-0 w-full h-full object-cover" alt="" />
)}
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, transparent 30%, rgba(0,0,0,0.5) 100%)' }} />
{/* Action buttons top-right */}
<div className="absolute top-3 right-3 z-[2] flex gap-1">
{onEdit && <button onClick={e => { e.stopPropagation(); onEdit(trip) }} className="w-[30px] h-[30px] rounded-[8px] bg-black/30 backdrop-blur-sm border border-white/20 flex items-center justify-center text-white"><Edit2 size={12} /></button>}
{onCopy && <button onClick={e => { e.stopPropagation(); onCopy(trip) }} className="w-[30px] h-[30px] rounded-[8px] bg-black/30 backdrop-blur-sm border border-white/20 flex items-center justify-center text-white"><Copy size={12} /></button>}
{onArchive && <button onClick={e => { e.stopPropagation(); onArchive(trip.id) }} className="w-[30px] h-[30px] rounded-[8px] bg-black/30 backdrop-blur-sm border border-white/20 flex items-center justify-center text-white"><Archive size={12} /></button>}
{onDelete && <button onClick={e => { e.stopPropagation(); onDelete(trip) }} className="w-[30px] h-[30px] rounded-[8px] bg-black/30 backdrop-blur-sm border border-white/20 flex items-center justify-center text-red-300"><Trash2 size={12} /></button>}
</div> </div>
{(onEdit || onCopy || onArchive || onDelete) && ( {/* Countdown badge */}
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid #f3f4f6', paddingTop: 10 }} {badgeText && (
onClick={e => e.stopPropagation()}> <div className="absolute top-3.5 left-3.5 z-[2]">
{onEdit && <CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label={t('common.edit')} />} <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-black/40 backdrop-blur-sm border border-white/15 rounded-full text-white text-[10px] font-bold uppercase tracking-[0.08em]">
{onCopy && <CardAction onClick={() => onCopy(trip)} icon={<Copy size={12} />} label={t('dashboard.copyTrip')} />} {status === 'ongoing' ? (
{onArchive && <CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label={t('dashboard.archive')} />} <span className="w-1.5 h-1.5 rounded-full bg-red-500 shadow-[0_0_6px_rgba(239,68,68,0.8)] animate-pulse" />
{onDelete && <CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label={t('common.delete')} danger />} ) : (
</div> <Clock size={10} />
)}
{badgeText}
</span>
</div>
)} )}
{/* Title on cover */}
<div className="absolute bottom-3.5 left-3.5 right-3.5 z-[2] text-white">
<h3 className="text-[22px] font-extrabold tracking-[-0.02em] leading-none">{trip.title}</h3>
{trip.description && (
<p className="text-[11px] opacity-75 font-medium mt-1 truncate">{trip.description}</p>
)}
</div>
</div>
{/* Bottom stats */}
<div className="flex items-center justify-between px-4 py-3">
<div className="flex gap-[18px]">
{trip.start_date && (
<div className="flex flex-col gap-px">
<span className="text-[13px] font-bold tracking-[-0.01em]" style={{ color: 'var(--text-primary)' }}>{formatDateShort(trip.start_date, locale)}</span>
<span className="text-[9px] uppercase tracking-[0.06em] font-medium" style={{ color: 'var(--text-faint)' }}>{t('dashboard.mobile.starts')}</span>
</div>
)}
{duration && (
<div className="flex flex-col gap-px">
<span className="text-[13px] font-bold tracking-[-0.01em]" style={{ color: 'var(--text-primary)' }}>{duration} {duration === 1 ? t('dashboard.mobile.day') : t('dashboard.mobile.days')}</span>
<span className="text-[9px] uppercase tracking-[0.06em] font-medium" style={{ color: 'var(--text-faint)' }}>{t('dashboard.mobile.duration')}</span>
</div>
)}
<div className="flex flex-col gap-px">
<span className="text-[13px] font-bold tracking-[-0.01em]" style={{ color: 'var(--text-primary)' }}>{trip.place_count || 0}</span>
<span className="text-[9px] uppercase tracking-[0.06em] font-medium" style={{ color: 'var(--text-faint)' }}>{t('dashboard.mobile.places')}</span>
</div>
{(trip.shared_count || 0) > 0 && (
<div className="flex flex-col gap-px">
<span className="text-[13px] font-bold tracking-[-0.01em]" style={{ color: 'var(--text-primary)' }}>{trip.shared_count}</span>
<span className="text-[9px] uppercase tracking-[0.06em] font-medium" style={{ color: 'var(--text-faint)' }}>{t('dashboard.mobile.buddies')}</span>
</div>
)}
</div>
</div>
</div>
)
}
// ── Regular Trip Card (matches mobile card design) ──────────────────────────
function TripCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): 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 (
<div
onClick={() => 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 */}
<div className="relative h-[140px] overflow-hidden" style={{ background: trip.cover_image ? undefined : tripGradient(trip.id) }}>
{trip.cover_image && (
<img src={trip.cover_image} className="absolute inset-0 w-full h-full object-cover" alt="" />
)}
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, transparent 30%, rgba(0,0,0,0.55) 100%)' }} />
{/* Action buttons top-right — visible on hover */}
<div className="absolute top-3 right-3 z-[2] flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{onEdit && <button onClick={e => { e.stopPropagation(); onEdit(trip) }} className="w-[30px] h-[30px] rounded-[8px] bg-black/30 backdrop-blur-sm border border-white/20 flex items-center justify-center text-white hover:bg-black/50 transition-colors"><Edit2 size={12} /></button>}
{onCopy && <button onClick={e => { e.stopPropagation(); onCopy(trip) }} className="w-[30px] h-[30px] rounded-[8px] bg-black/30 backdrop-blur-sm border border-white/20 flex items-center justify-center text-white hover:bg-black/50 transition-colors"><Copy size={12} /></button>}
{onArchive && <button onClick={e => { e.stopPropagation(); onArchive(trip.id) }} className="w-[30px] h-[30px] rounded-[8px] bg-black/30 backdrop-blur-sm border border-white/20 flex items-center justify-center text-white hover:bg-black/50 transition-colors"><Archive size={12} /></button>}
{onDelete && <button onClick={e => { e.stopPropagation(); onDelete(trip) }} className="w-[30px] h-[30px] rounded-[8px] bg-black/30 backdrop-blur-sm border border-white/20 flex items-center justify-center text-red-300 hover:bg-red-500/30 transition-colors"><Trash2 size={12} /></button>}
</div>
{/* Status badge top-left */}
{badgeText && (
<div className="absolute top-3.5 left-3.5 z-[2]">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-black/40 backdrop-blur-sm border border-white/15 rounded-full text-white text-[10px] font-bold uppercase tracking-[0.08em]">
{status === 'ongoing' ? (
<span className="w-1.5 h-1.5 rounded-full bg-red-500 shadow-[0_0_6px_rgba(239,68,68,0.8)] animate-pulse" />
) : (
<Clock size={10} />
)}
{badgeText}
</span>
</div>
)}
{/* Shared badge */}
{!trip.is_owner && (
<div className="absolute top-3.5 right-3.5 z-[1] group-hover:opacity-0 transition-opacity">
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-black/40 backdrop-blur-sm border border-white/15 rounded-full text-white text-[9px] font-semibold uppercase tracking-[0.06em]">
<Users size={9} /> {t('dashboard.shared')}
</span>
</div>
)}
{/* Title on cover */}
<div className="absolute bottom-3.5 left-3.5 right-3.5 z-[2] text-white">
<h3 className="text-[20px] font-extrabold tracking-[-0.02em] leading-tight">{trip.title}</h3>
{trip.description && (
<p className="text-[11px] opacity-75 font-medium mt-1 truncate">{trip.description}</p>
)}
</div>
</div>
{/* Bottom stats */}
<div className="flex items-center justify-between px-4 py-3">
<div className="flex gap-[18px]">
{trip.start_date && (
<div className="flex flex-col gap-px">
<span className="text-[13px] font-bold tracking-[-0.01em]" style={{ color: 'var(--text-primary)' }}>{formatDateShort(trip.start_date, locale)}</span>
<span className="text-[9px] uppercase tracking-[0.06em] font-medium" style={{ color: 'var(--text-faint)' }}>{t('dashboard.mobile.starts')}</span>
</div>
)}
{duration && (
<div className="flex flex-col gap-px">
<span className="text-[13px] font-bold tracking-[-0.01em]" style={{ color: 'var(--text-primary)' }}>{duration} {duration === 1 ? t('dashboard.mobile.day') : t('dashboard.mobile.days')}</span>
<span className="text-[9px] uppercase tracking-[0.06em] font-medium" style={{ color: 'var(--text-faint)' }}>{t('dashboard.mobile.duration')}</span>
</div>
)}
<div className="flex flex-col gap-px">
<span className="text-[13px] font-bold tracking-[-0.01em]" style={{ color: 'var(--text-primary)' }}>{trip.place_count || 0}</span>
<span className="text-[9px] uppercase tracking-[0.06em] font-medium" style={{ color: 'var(--text-faint)' }}>{t('dashboard.mobile.places')}</span>
</div>
{(trip.shared_count || 0) > 0 && (
<div className="flex flex-col gap-px">
<span className="text-[13px] font-bold tracking-[-0.01em]" style={{ color: 'var(--text-primary)' }}>{trip.shared_count}</span>
<span className="text-[9px] uppercase tracking-[0.06em] font-medium" style={{ color: 'var(--text-faint)' }}>{t('dashboard.mobile.buddies')}</span>
</div>
)}
</div>
</div> </div>
</div> </div>
) )
@@ -415,7 +547,7 @@ function TripListItem({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, l
<MapPin size={11} /> {trip.place_count || 0} <MapPin size={11} /> {trip.place_count || 0}
</div> </div>
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)' }}> <div className="hidden md:flex" style={{ alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)' }}>
<Users size={11} /> {trip.shared_count+1 || 0} <Users size={11} /> {trip.shared_count || 0}
</div> </div>
</div> </div>
@@ -553,7 +685,7 @@ export default function DashboardPage(): React.ReactElement {
const [showForm, setShowForm] = useState<boolean>(false) const [showForm, setShowForm] = useState<boolean>(false)
const [editingTrip, setEditingTrip] = useState<DashboardTrip | null>(null) const [editingTrip, setEditingTrip] = useState<DashboardTrip | null>(null)
const [showArchived, setShowArchived] = useState<boolean>(false) const [showArchived, setShowArchived] = useState<boolean>(false)
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile'>(false) const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile' | 'mobile-currency' | 'mobile-timezone'>(false)
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid') const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid')
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null) const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
@@ -568,7 +700,7 @@ export default function DashboardPage(): React.ReactElement {
const navigate = useNavigate() const navigate = useNavigate()
const toast = useToast() const toast = useToast()
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const { demoMode } = useAuthStore() const { demoMode, user } = useAuthStore()
const { settings, updateSetting } = useSettingsStore() const { settings, updateSetting } = useSettingsStore()
const can = useCanDo() const can = useCanDo()
const dm = settings.dark_mode const dm = settings.dark_mode
@@ -578,7 +710,7 @@ export default function DashboardPage(): React.ReactElement {
const showSidebar = showCurrency || showTimezone const showSidebar = showCurrency || showTimezone
useEffect(() => { useEffect(() => {
if (showWidgetSettings === 'mobile') { if (showWidgetSettings === 'mobile' || showWidgetSettings === 'mobile-currency' || showWidgetSettings === 'mobile-timezone') {
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
} else { } else {
document.body.style.overflow = '' document.body.style.overflow = ''
@@ -689,10 +821,183 @@ export default function DashboardPage(): React.ReactElement {
<Navbar /> <Navbar />
{demoMode && <DemoBanner />} {demoMode && <DemoBanner />}
<div style={{ flex: 1, overflow: 'auto', overscrollBehavior: 'contain', marginTop: 'var(--nav-h)' }}> <div style={{ flex: 1, overflow: 'auto', overscrollBehavior: 'contain', marginTop: 'var(--nav-h)' }}>
<div style={{ maxWidth: 1300, margin: '0 auto', padding: '32px 20px 60px' }}> <div style={{ maxWidth: 1300, margin: '0 auto', paddingTop: 32, paddingLeft: 20, paddingRight: 20, paddingBottom: 'calc(100px + env(safe-area-inset-bottom, 0px))' }}>
{/* Header */} {/* Mobile greeting header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 28 }}> <div className="md:hidden flex items-center justify-between mb-5">
<div>
<p className="text-[12px] text-zinc-500 font-medium">{new Date().getHours() < 12 ? t('dashboard.greeting.morning') : new Date().getHours() < 18 ? t('dashboard.greeting.afternoon') : t('dashboard.greeting.evening')}</p>
<p className="text-[22px] font-extrabold tracking-[-0.025em] leading-tight" style={{ color: 'var(--text-primary)' }}>{user?.username || t('nav.profile')}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => navigate('/notifications')}
className="w-10 h-10 rounded-xl flex items-center justify-center relative"
style={{ background: 'var(--bg-card)', border: '1px solid var(--border-primary)', color: 'var(--text-secondary)' }}
>
<Bell size={18} />
</button>
<button
onClick={() => navigate('/settings')}
className="w-10 h-10 rounded-full flex items-center justify-center text-[15px] font-bold text-white overflow-hidden"
style={{ background: user?.avatar_url ? undefined : 'linear-gradient(135deg, #6366F1, #8B5CF6)' }}
>
{user?.avatar_url
? <img src={user.avatar_url} className="w-full h-full object-cover" alt="" />
: (user?.username || '?')[0].toUpperCase()
}
</button>
</div>
</div>
{/* 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 (
<div className="md:hidden mb-5">
<div
onClick={() => navigate(`/trips/${liveTrip.id}`)}
className="relative rounded-3xl overflow-hidden cursor-pointer"
style={{ minHeight: 340 }}
>
{/* Background */}
<div className="absolute inset-0" style={{
background: liveTrip.cover_image ? undefined : `radial-gradient(circle at 15% 20%, rgba(16,185,129,0.7), transparent 45%), radial-gradient(circle at 85% 80%, rgba(6,182,212,0.6), transparent 50%), radial-gradient(circle at 50% 50%, rgba(14,165,233,0.4), transparent 55%), linear-gradient(135deg, #064E3B 0%, #065F46 35%, #0E7490 75%, #164E63 100%)`
}}>
{liveTrip.cover_image && (
<>
<img src={liveTrip.cover_image} className="w-full h-full object-cover" alt="" />
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.6) 100%)' }} />
</>
)}
</div>
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, transparent 0%, transparent 40%, rgba(0,0,0,0.5) 100%)' }} />
{/* Content */}
<div className="relative p-5 flex flex-col text-white z-[2]" style={{ minHeight: 340 }}>
{/* Top badges */}
<div className="flex items-center justify-between mb-5">
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black/40 backdrop-blur-sm border border-white/15 rounded-full text-[10px] font-bold uppercase tracking-[0.1em]">
<span className="w-1.5 h-1.5 rounded-full bg-red-500 shadow-[0_0_6px_rgba(239,68,68,0.8)] animate-pulse" />
{t("dashboard.mobile.liveNow")}
</span>
<div className="flex gap-1.5">
<button
onClick={e => { e.stopPropagation(); setEditingTrip(liveTrip); setShowForm(true) }}
className="w-[34px] h-[34px] rounded-[10px] bg-white/12 backdrop-blur-sm border border-white/15 flex items-center justify-center text-white hover:bg-white/20"
>
<Edit2 size={14} />
</button>
<button
onClick={e => { e.stopPropagation(); handleCopy(liveTrip) }}
className="w-[34px] h-[34px] rounded-[10px] bg-white/12 backdrop-blur-sm border border-white/15 flex items-center justify-center text-white hover:bg-white/20"
>
<Copy size={14} />
</button>
<button
onClick={e => { e.stopPropagation(); handleArchive(liveTrip.id) }}
className="w-[34px] h-[34px] rounded-[10px] bg-white/12 backdrop-blur-sm border border-white/15 flex items-center justify-center text-white hover:bg-white/20"
>
<Archive size={14} />
</button>
<button
onClick={e => { e.stopPropagation(); handleDelete(liveTrip) }}
className="w-[34px] h-[34px] rounded-[10px] bg-white/12 backdrop-blur-sm border border-white/15 flex items-center justify-center text-red-300 hover:bg-red-500/20"
>
<Trash2 size={14} />
</button>
</div>
</div>
{/* Title area */}
<div className="flex-1 flex flex-col justify-end mb-4">
<h2 className="text-[32px] font-extrabold tracking-[-0.03em] leading-[0.95] mb-1.5">{liveTrip.title}</h2>
<p className="text-[12px] opacity-80 font-medium">
{formatDateShort(liveTrip.start_date)} {formatDateShort(liveTrip.end_date)} · {t('journey.pdf.day')} {currentDay} / {totalDays}
</p>
</div>
{/* Progress */}
<div className="mb-4">
<div className="flex justify-between text-[11px] font-semibold mb-1.5">
<span className="opacity-85">{t('dashboard.mobile.tripProgress')}</span>
<span className="opacity-70">{daysLeft} days left</span>
</div>
<div className="h-1.5 bg-white/15 rounded-full overflow-hidden">
<div className="h-full bg-white rounded-full relative" style={{ width: `${progress}%` }}>
<span className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow-[0_0_12px_rgba(255,255,255,0.9)]" />
</div>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-2.5 p-3.5 bg-black/25 backdrop-blur-sm border border-white/10 rounded-2xl">
<div className="text-center">
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none">{liveTrip.place_count || 0}</p>
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">Places</p>
</div>
<div className="text-center">
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none">{liveTrip.shared_count || 0}</p>
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">Buddies</p>
</div>
</div>
</div>
</div>
</div>
)
})()}
{/* Mobile: Quick Actions */}
<div className="md:hidden grid grid-cols-3 gap-2 mb-6">
{can('trip_create') && (
<button
onClick={() => { setEditingTrip(null); setShowForm(true) }}
className="flex flex-col items-center gap-2 py-3.5 rounded-2xl border border-zinc-200 dark:border-zinc-700"
style={{ background: 'var(--bg-card)' }}
>
<div className="w-9 h-9 rounded-[11px] flex items-center justify-center" style={{ background: '#FEF3C7', color: '#B45309' }}>
<Plus size={16} />
</div>
<span className="text-[10px] font-semibold" style={{ color: 'var(--text-primary)' }}>{t('dashboard.mobile.newTrip')}</span>
</button>
)}
{showCurrency && (
<button
onClick={() => setShowWidgetSettings('mobile-currency')}
className="flex flex-col items-center gap-2 py-3.5 rounded-2xl border border-zinc-200 dark:border-zinc-700"
style={{ background: 'var(--bg-card)' }}
>
<div className="w-9 h-9 rounded-[11px] flex items-center justify-center" style={{ background: '#DBEAFE', color: '#1E40AF' }}>
<ArrowRightLeft size={16} />
</div>
<span className="text-[10px] font-semibold" style={{ color: 'var(--text-primary)' }}>{t('dashboard.mobile.currency')}</span>
</button>
)}
{showTimezone && (
<button
onClick={() => setShowWidgetSettings('mobile-timezone')}
className="flex flex-col items-center gap-2 py-3.5 rounded-2xl border border-zinc-200 dark:border-zinc-700"
style={{ background: 'var(--bg-card)' }}
>
<div className="w-9 h-9 rounded-[11px] flex items-center justify-center" style={{ background: '#DCFCE7', color: '#15803D' }}>
<Clock size={16} />
</div>
<span className="text-[10px] font-semibold" style={{ color: 'var(--text-primary)' }}>{t('dashboard.mobile.timezone')}</span>
</button>
)}
</div>
{/* Desktop header */}
<div className="hidden md:flex" style={{ alignItems: 'center', justifyContent: 'space-between', marginBottom: 28 }}>
<div> <div>
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 800, color: 'var(--text-primary)' }}>{t('dashboard.title')}</h1> <h1 style={{ margin: 0, fontSize: 24, fontWeight: 800, color: 'var(--text-primary)' }}>{t('dashboard.title')}</h1>
<p style={{ margin: '3px 0 0', fontSize: 13, color: '#9ca3af' }}> <p style={{ margin: '3px 0 0', fontSize: 13, color: '#9ca3af' }}>
@@ -774,17 +1079,7 @@ export default function DashboardPage(): React.ReactElement {
</div> </div>
)} )}
{/* Mobile widgets button */} {/* Mobile widgets button — replaced by Quick Actions */}
{showSidebar && (
<button
onClick={() => setShowWidgetSettings('mobile')}
className="lg:hidden flex items-center justify-center gap-2 w-full py-2.5 rounded-xl text-xs font-semibold mb-4"
style={{ background: 'var(--bg-card)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)' }}
>
<ArrowRightLeft size={13} style={{ color: 'var(--text-faint)' }} />
{showCurrency && showTimezone ? `${t('dashboard.currency')} & ${t('dashboard.timezone')}` : showCurrency ? t('dashboard.currency') : t('dashboard.timezone')}
</button>
)}
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}> <div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}>
{/* Main content */} {/* Main content */}
@@ -819,9 +1114,9 @@ export default function DashboardPage(): React.ReactElement {
</div> </div>
)} )}
{/* Spotlight (grid mode only) */} {/* Spotlight (grid mode, desktop only — mobile has Live Hero) */}
{!isLoading && spotlight && viewMode === 'grid' && ( {!isLoading && spotlight && viewMode === 'grid' && (
<SpotlightCard <div className="hidden md:block"><SpotlightCard
trip={spotlight} trip={spotlight}
t={t} locale={locale} dark={dark} t={t} locale={locale} dark={dark}
onEdit={(can('trip_edit', spotlight) || can('trip_cover_upload', spotlight)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined} onEdit={(can('trip_edit', spotlight) || can('trip_cover_upload', spotlight)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
@@ -829,13 +1124,37 @@ export default function DashboardPage(): React.ReactElement {
onDelete={can('trip_delete', spotlight) ? handleDelete : undefined} onDelete={can('trip_delete', spotlight) ? handleDelete : undefined}
onArchive={can('trip_archive', spotlight) ? handleArchive : undefined} onArchive={can('trip_archive', spotlight) ? handleArchive : undefined}
onClick={tr => navigate(`/trips/${tr.id}`)} onClick={tr => navigate(`/trips/${tr.id}`)}
/> /></div>
)} )}
{/* Trips — grid or list */} {/* Trips — mobile cards */}
{!isLoading && (viewMode === 'grid' ? rest : trips).length > 0 && ( {!isLoading && rest.length > 0 && (
<div className="md:hidden flex flex-col gap-3 mb-10">
<div className="flex items-baseline justify-between px-1 pb-1">
<span className="text-[11px] font-bold tracking-[0.12em] uppercase" style={{ color: 'var(--text-faint)' }}>
{rest.some(t => getTripStatus(t) === 'future' || getTripStatus(t) === 'tomorrow') ? t('dashboard.mobile.upcomingTrips') : t('dashboard.mobile.yourTrips')}
</span>
<span className="text-[11px] font-medium" style={{ color: 'var(--text-muted)' }}>{rest.length} {t('dashboard.mobile.trips')}</span>
</div>
{rest.map(trip => (
<MobileTripCard
key={trip.id}
trip={trip}
t={t} locale={locale}
onEdit={(can('trip_edit', trip) || can('trip_cover_upload', trip)) ? tr => { 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}`)}
/>
))}
</div>
)}
{/* Trips — desktop grid or list */}
{!isLoading && (viewMode === 'grid' ? rest : rest).length > 0 && (
viewMode === 'grid' ? ( viewMode === 'grid' ? (
<div className="trip-grid" style={{ display: 'grid', gap: 16, marginBottom: 40 }}> <div className="trip-grid hidden md:grid" style={{ gap: 16, marginBottom: 40 }}>
{rest.map(trip => ( {rest.map(trip => (
<TripCard <TripCard
key={trip.id} key={trip.id}
@@ -850,8 +1169,8 @@ export default function DashboardPage(): React.ReactElement {
))} ))}
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 40 }}> <div className="hidden md:flex" style={{ flexDirection: 'column', gap: 8, marginBottom: 40 }}>
{trips.map(trip => ( {rest.map(trip => (
<TripListItem <TripListItem
key={trip.id} key={trip.id}
trip={trip} trip={trip}
@@ -912,20 +1231,25 @@ export default function DashboardPage(): React.ReactElement {
</div> </div>
{/* Mobile widgets bottom sheet */} {/* Mobile widgets bottom sheet */}
{showWidgetSettings === 'mobile' && ( {(showWidgetSettings === 'mobile' || showWidgetSettings === 'mobile-currency' || showWidgetSettings === 'mobile-timezone') && (
<div className="lg:hidden fixed inset-0 z-50" style={{ background: 'rgba(0,0,0,0.3)', touchAction: 'none' }} onClick={() => setShowWidgetSettings(false)}> <div className="lg:hidden fixed inset-0 z-50" style={{ background: 'rgba(0,0,0,0.3)', touchAction: 'none' }} onClick={() => setShowWidgetSettings(false)}>
<div className="absolute bottom-0 left-0 right-0 flex flex-col overflow-hidden" <div className="absolute left-0 right-0 flex flex-col overflow-hidden"
style={{ maxHeight: '80vh', background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', overscrollBehavior: 'contain' }} style={{ bottom: 'calc(84px + env(safe-area-inset-bottom, 0px))', maxHeight: '70vh', background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', overscrollBehavior: 'contain', animation: 'slideUp 0.25s ease-out' }}
onClick={e => e.stopPropagation()}> onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--border-secondary)' }}> <div className="flex justify-center pt-3 pb-2">
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Widgets</span> <div className="w-10 h-1 rounded-full" style={{ background: 'var(--border-primary)' }} />
</div>
<div className="flex items-center justify-between px-5 pb-3">
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{showWidgetSettings === 'mobile-currency' ? t('dashboard.mobile.currencyConverter') : showWidgetSettings === 'mobile-timezone' ? t('dashboard.mobile.timezone') : t('common.settings')}
</span>
<button onClick={() => setShowWidgetSettings(false)} className="w-7 h-7 rounded-full flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}> <button onClick={() => setShowWidgetSettings(false)} className="w-7 h-7 rounded-full flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}>
<X size={14} style={{ color: 'var(--text-primary)' }} /> <X size={14} style={{ color: 'var(--text-primary)' }} />
</button> </button>
</div> </div>
<div className="flex-1 overflow-auto p-4 space-y-4"> <div className="flex-1 overflow-auto p-4 space-y-4" style={{ borderTop: '1px solid var(--border-secondary)' }}>
{showCurrency && <CurrencyWidget />} {(showWidgetSettings === 'mobile' || showWidgetSettings === 'mobile-currency') && showCurrency && <CurrencyWidget />}
{showTimezone && <TimezoneWidget />} {(showWidgetSettings === 'mobile' || showWidgetSettings === 'mobile-timezone') && showTimezone && <TimezoneWidget />}
</div> </div>
</div> </div>
</div> </div>
File diff suppressed because it is too large Load Diff
+444
View File
@@ -0,0 +1,444 @@
import { useEffect, useState, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { useJourneyStore } from '../store/journeyStore'
import { journeyApi } from '../api/client'
import Navbar from '../components/Layout/Navbar'
import { useToast } from '../components/shared/Toast'
import { useTranslation } from '../i18n'
import {
Plus, Search, Sparkles, Calendar, MapPin, BookOpen, Camera,
Check, X, ChevronRight, RefreshCw, Users,
} from 'lucide-react'
import type { Journey } 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]
}
function timeAgo(timestamp: number, t: (k: string, p?: any) => string): string {
const diff = Date.now() - timestamp
const hours = Math.floor(diff / 3600000)
if (hours < 1) return t('common.justNow')
if (hours < 24) return t('common.hoursAgo', { count: hours })
const days = Math.floor(hours / 24)
return t('common.daysAgo', { count: days })
}
export default function JourneyPage() {
const navigate = useNavigate()
const toast = useToast()
const { t } = useTranslation()
const { journeys, loading, loadJourneys, createJourney } = useJourneyStore()
const [showCreate, setShowCreate] = useState(false)
const [newTitle, setNewTitle] = useState('')
const [availableTrips, setAvailableTrips] = useState<any[]>([])
const [selectedTripIds, setSelectedTripIds] = useState<Set<number>>(new Set())
// suggestion
const [suggestions, setSuggestions] = useState<any[]>([])
const [dismissedSuggestions, setDismissedSuggestions] = useState<Set<number>>(new Set())
useEffect(() => {
loadJourneys()
journeyApi.suggestions().then(d => setSuggestions(d.trips || [])).catch(() => {})
}, [])
const activeSuggestion = suggestions.find(s => !dismissedSuggestions.has(s.id))
const activeJourney = useMemo(() => {
return journeys.find(j => j.status === 'active') || null
}, [journeys])
const otherJourneys = useMemo(() => {
return journeys.filter(j => j.id !== activeJourney?.id)
}, [journeys, activeJourney])
const openCreateModal = async (preSelectedTripId?: number) => {
setShowCreate(true)
setNewTitle('')
const initial = new Set<number>()
if (preSelectedTripId) initial.add(preSelectedTripId)
setSelectedTripIds(initial)
try {
const data = await journeyApi.availableTrips()
setAvailableTrips(data.trips || [])
} catch {}
}
const handleCreate = async () => {
if (!newTitle.trim()) return
try {
const j = await createJourney({
title: newTitle.trim(),
trip_ids: [...selectedTripIds],
})
setShowCreate(false)
navigate(`/journey/${j.id}`)
} catch {
toast.error(t('journey.createError'))
}
}
const totalPlaces = useMemo(() => {
return availableTrips.filter(t => selectedTripIds.has(t.id)).reduce((sum: number, t: any) => sum + (t.place_count || 0), 0)
}, [availableTrips, selectedTripIds])
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<Navbar />
<div style={{ paddingTop: 'var(--nav-h, 56px)' }}>
<div className="max-w-[1440px] mx-auto">
{/* Header — mobile: just a create button */}
<div className="md:hidden px-5 pt-5 pb-4">
<button
onClick={() => openCreateModal()}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[14px] font-semibold active:scale-[0.98] transition-transform"
>
<Plus size={16} strokeWidth={2.5} />
{t('journey.frontpage.createJourney')}
</button>
</div>
{/* Header — desktop */}
<div className="hidden md:flex items-start justify-between px-8 pt-10 pb-7">
<div>
<h1 className="text-[32px] font-extrabold tracking-[-0.025em] text-zinc-900 dark:text-white leading-none">{t('journey.title')}</h1>
<p className="text-[13px] text-zinc-500 mt-1.5">{t("journey.frontpage.subtitle")}</p>
</div>
<div className="flex items-center gap-2">
<button className="w-9 h-9 rounded-[10px] border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 flex items-center justify-center text-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-700">
<Search size={15} />
</button>
<button
onClick={() => openCreateModal()}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-[10px] bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-all hover:-translate-y-px"
>
<Plus size={14} />
{t('journey.frontpage.createJourney')}
</button>
</div>
</div>
<div className="px-4 md:px-8 pb-16">
{/* Suggestion banner */}
{activeSuggestion && (
<div className="relative rounded-2xl overflow-hidden mb-8" style={{ background: 'linear-gradient(135deg, #1E293B 0%, #334155 100%)' }}>
<div className="absolute inset-0 pointer-events-none hidden md:block" style={{ background: 'radial-gradient(circle at 85% 50%, rgba(99,102,241,0.4), transparent 50%), radial-gradient(circle at 100% 100%, rgba(236,72,153,0.3), transparent 50%)' }} />
<div className="absolute inset-0 pointer-events-none md:hidden" style={{ background: 'radial-gradient(circle at 80% 20%, rgba(99,102,241,0.5), transparent 60%), radial-gradient(circle at 20% 90%, rgba(236,72,153,0.35), transparent 60%)' }} />
<div className="relative flex flex-col md:flex-row md:items-center justify-between gap-4 md:gap-6 p-5 text-white">
<div className="flex items-center gap-3.5">
<div className="w-10 h-10 rounded-[10px] bg-white/15 backdrop-blur flex items-center justify-center flex-shrink-0">
<Sparkles size={18} />
</div>
<div>
<div className="text-[10px] font-semibold tracking-[0.12em] uppercase opacity-70">{t("journey.frontpage.suggestionLabel")}</div>
<div className="text-[13px] mt-0.5">
<span dangerouslySetInnerHTML={{ __html: t('journey.frontpage.suggestionText', { title: activeSuggestion.title }) }} />
</div>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => setDismissedSuggestions(prev => new Set([...prev, activeSuggestion.id]))}
className="px-3 py-1.5 rounded-lg bg-white/10 border border-white/20 text-[12px] font-medium text-white hover:bg-white/20"
>
{t('journey.frontpage.dismiss')}
</button>
<button
onClick={() => openCreateModal(activeSuggestion.id)}
className="px-3 py-1.5 rounded-lg bg-white text-zinc-900 text-[12px] font-medium hover:bg-zinc-100"
>
{t('journey.frontpage.createJourney')}
</button>
</div>
</div>
</div>
)}
{/* Active Journey Hero */}
{activeJourney && (
<div className="mb-10">
<div className="flex items-center justify-between mb-3">
<span className="text-[11px] font-bold tracking-[0.14em] uppercase text-zinc-500">{t("journey.frontpage.activeJourney")}</span>
<span className="text-[11px] text-zinc-400 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
{t('journey.frontpage.updated', { time: timeAgo(activeJourney.updated_at, t) })}
</span>
</div>
<div
onClick={() => navigate(`/journey/${activeJourney.id}`)}
className="relative rounded-3xl overflow-hidden cursor-pointer transition-all duration-300 hover:-translate-y-1 hover:shadow-xl h-[340px] md:h-[400px]"
style={{ background: pickGradient(activeJourney.id) }}
>
{/* Cover image */}
{activeJourney.cover_image && (
<div className="absolute inset-0 z-[1]">
<img src={`/uploads/${activeJourney.cover_image}`} className="w-full h-full object-cover" alt="" />
<div className="absolute inset-0" style={{ background: pickGradient(activeJourney.id), opacity: 0.45 }} />
</div>
)}
{/* Gradient overlays */}
<div className="absolute inset-0 pointer-events-none z-[2]" style={{ background: 'radial-gradient(circle at 15% 20%, rgba(236,72,153,0.35), transparent 40%), radial-gradient(circle at 85% 80%, rgba(251,146,60,0.3), transparent 45%), radial-gradient(circle at 50% 50%, rgba(99,102,241,0.25), transparent 50%)' }} />
<div className="absolute inset-0 pointer-events-none z-[2]" style={{ background: 'linear-gradient(180deg, transparent 0%, transparent 50%, rgba(0,0,0,0.4) 100%), linear-gradient(90deg, rgba(0,0,0,0.15) 0%, transparent 50%)' }} />
<div className="relative h-full p-6 md:p-8 flex flex-col z-[3] text-white">
{/* Top badges */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-2 px-3 py-1.5 bg-white/12 backdrop-blur-sm border border-white/15 rounded-full text-[10px] font-semibold uppercase tracking-[0.08em]">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.6)] animate-pulse" />
{t('journey.frontpage.live')}
</span>
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-white/12 backdrop-blur-sm border border-white/15 rounded-full text-[10px] font-medium">
<RefreshCw size={10} />
{t('journey.frontpage.synced')}
</span>
</div>
</div>
{/* Middle — title */}
<div className="flex-1 flex flex-col justify-center py-4">
{activeJourney.subtitle && (
<p className="text-[13px] font-medium opacity-85 mb-3">{activeJourney.subtitle}</p>
)}
<h2 className="text-[40px] md:text-[56px] font-extrabold tracking-[-0.035em] leading-[0.95] mb-3" style={{ textShadow: '0 2px 30px rgba(0,0,0,0.15)' }}>
{activeJourney.title}
</h2>
</div>
{/* Bottom stats */}
<div className="flex items-end justify-between gap-6">
<div className="flex gap-7">
{[
{ val: (activeJourney as any).entry_count ?? '--', label: t("journey.stats.entries") },
{ val: (activeJourney as any).photo_count ?? '--', label: t("journey.stats.photos") },
{ val: (activeJourney as any).city_count ?? '--', label: t("journey.stats.cities") },
].map(s => (
<div key={s.label} className="flex flex-col gap-1">
<span className="text-[28px] font-extrabold tracking-[-0.02em] leading-none">{s.val}</span>
<span className="text-[10px] uppercase tracking-[0.12em] opacity-70 font-semibold">{s.label}</span>
</div>
))}
</div>
<span className="hidden md:inline-flex items-center gap-1.5 px-3 py-1.5 bg-white/15 backdrop-blur-sm rounded-full text-[11px] font-medium">
{t('journey.frontpage.continueWriting')}<ChevronRight size={12} />
</span>
</div>
</div>
</div>
</div>
)}
{/* All Journeys */}
<div className="mb-4 flex items-center justify-between">
<span className="text-[11px] font-bold tracking-[0.14em] uppercase text-zinc-500">{t("journey.frontpage.allJourneys")}</span>
<span className="text-[11px] text-zinc-400">{journeys.length} {t('journey.frontpage.journeys')}</span>
</div>
{loading && journeys.length === 0 ? (
<div className="flex justify-center py-16">
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-[18px]">
{otherJourneys.map(j => (
<JourneyCard key={j.id} journey={j} onClick={() => navigate(`/journey/${j.id}`)} />
))}
{/* Create card */}
<button
onClick={() => openCreateModal()}
className="group min-h-[320px] rounded-2xl border-[1.5px] border-dashed border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex flex-col items-center justify-center gap-2.5 hover:border-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-all cursor-pointer hover:-translate-y-0.5"
>
<div className="w-14 h-14 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-400 group-hover:bg-white dark:group-hover:bg-zinc-700 transition-all group-hover:rotate-90 duration-300">
<Plus size={22} />
</div>
<span className="text-[14px] font-semibold text-zinc-700 dark:text-zinc-300">{t("journey.frontpage.createNew")}</span>
<span className="text-[12px] text-zinc-400 max-w-[180px] text-center leading-snug">{t("journey.frontpage.createNewSub")}</span>
</button>
</div>
)}
</div>
</div>
</div>
{/* Create Modal */}
{showCreate && (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}>
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="px-7 pt-6 pb-5 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[18px] font-bold tracking-[-0.01em] text-zinc-900 dark:text-white">{t("journey.frontpage.createJourney")}</h2>
<p className="text-[13px] text-zinc-500 mt-1">{t('journey.frontpage.createNewSub')}</p>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-7 py-5">
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2.5">{t('journey.frontpage.journeyName')}</label>
<input
value={newTitle}
onChange={e => setNewTitle(e.target.value)}
placeholder={t('journey.frontpage.namePlaceholder')}
className="w-full px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white focus:border-zinc-900 dark:focus:border-zinc-400 focus:outline-none mb-5"
/>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2.5">{t('journey.frontpage.selectTrips')}</label>
<div className="flex flex-col gap-2 max-h-[320px] overflow-y-auto">
{availableTrips.map(trip => {
const selected = selectedTripIds.has(trip.id)
const status = trip.end_date && trip.end_date < new Date().toISOString().split('T')[0]
? 'completed'
: trip.start_date && trip.start_date <= new Date().toISOString().split('T')[0]
? 'active'
: 'upcoming'
const statusColors: Record<string, string> = {
completed: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400',
active: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
upcoming: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400',
}
return (
<div
key={trip.id}
onClick={() => {
setSelectedTripIds(prev => {
const next = new Set(prev)
if (next.has(trip.id)) next.delete(trip.id)
else next.add(trip.id)
return next
})
}}
className={`flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-all ${
selected
? 'border-zinc-900 dark:border-zinc-400 bg-zinc-50 dark:bg-zinc-800'
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500'
}`}
>
<div className={`w-5 h-5 rounded-md border-2 flex items-center justify-center flex-shrink-0 ${
selected
? 'bg-zinc-900 dark:bg-white border-zinc-900 dark:border-white'
: 'border-zinc-300 dark:border-zinc-600'
}`}>
{selected && <Check size={12} className="text-white dark:text-zinc-900" />}
</div>
<div className="w-12 h-12 rounded-lg flex-shrink-0" style={{ background: pickGradient(trip.id) }} />
<div className="flex-1 min-w-0">
<div className="text-[14px] font-semibold text-zinc-900 dark:text-white">{trip.title}</div>
<div className="text-[12px] text-zinc-500 flex items-center gap-2.5 mt-0.5">
<span className="flex items-center gap-1"><Calendar size={11} /> {trip.start_date ? Math.ceil((new Date(trip.end_date || trip.start_date).getTime() - new Date(trip.start_date).getTime()) / 86400000) + 1 : '?'}<span className="hidden md:inline"> {t('journey.stats.days').toLowerCase()}</span></span>
<span className="flex items-center gap-1"><MapPin size={11} /> {trip.place_count || 0}<span className="hidden md:inline"> {t("journey.frontpage.places")}</span></span>
</div>
</div>
<span className={`text-[10px] font-medium uppercase tracking-[0.05em] px-2 py-0.5 rounded-full ${statusColors[status]}`}>
{t(`journey.status.${status}`)}
</span>
</div>
)
})}
</div>
</div>
{/* Footer */}
<div className="px-7 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex items-center justify-between">
<div className="text-[12px] text-zinc-500">
<strong className="text-zinc-900 dark:text-white">{selectedTripIds.size}</strong> <span className="hidden md:inline">{t('journey.frontpage.tripsSelected')}</span><span className="md:hidden">{t('journey.frontpage.trips')}</span>
{selectedTripIds.size > 0 && <> · <strong className="text-zinc-900 dark:text-white">{totalPlaces}</strong> <span className="hidden md:inline">{t('journey.frontpage.placesImported')}</span><span className="md:hidden">{t('journey.frontpage.places')}</span></>}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowCreate(false)}
className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700"
>
{t('common.cancel')}
</button>
<button
onClick={handleCreate}
disabled={!newTitle.trim()}
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
<span className="md:hidden">{t('journey.create')}</span><span className="hidden md:inline">{t('journey.frontpage.createJourney')}</span>
</button>
</div>
</div>
</div>
</div>
)}
</div>
)
}
function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?: number; photo_count?: number; city_count?: number }; onClick: () => void }) {
const { t } = useTranslation()
const j = journey
const entryCount = j.entry_count ?? 0
const photoCount = j.photo_count ?? 0
const cityCount = j.city_count ?? 0
return (
<div
onClick={onClick}
className="rounded-2xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 overflow-hidden cursor-pointer transition-all duration-250 hover:border-zinc-400 hover:-translate-y-1 hover:shadow-[0_20px_40px_rgba(0,0,0,0.06)] flex flex-col"
>
{/* Cover */}
<div className="h-[170px] relative overflow-hidden" style={{ background: pickGradient(j.id) }}>
{j.cover_image && (
<>
<img src={`/uploads/${j.cover_image}`} className="absolute inset-0 w-full h-full object-cover" alt="" />
<div className="absolute inset-0" style={{ background: pickGradient(j.id), opacity: 0.4 }} />
</>
)}
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, transparent 50%, rgba(0,0,0,0.4) 100%)' }} />
{/* Top overlay */}
<div className="absolute top-3.5 left-3.5 right-3.5 flex items-start justify-between z-[2]">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-black/45 backdrop-blur-sm rounded-full text-white text-[10px] font-semibold tracking-wide">
<Calendar size={10} />
{new Date(j.created_at).getFullYear()}
</span>
</div>
</div>
{/* Body */}
<div className="px-[18px] pt-4 pb-[18px] flex flex-col flex-1">
<h3 className="text-[16px] font-bold tracking-[-0.01em] text-zinc-900 dark:text-white">{j.title}</h3>
{j.subtitle && (
<p className="text-[12px] text-zinc-500 mt-1">{j.subtitle}</p>
)}
{j.status === 'draft' && (
<span className="inline-flex self-start mt-1.5 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 uppercase tracking-wide">{t('journey.status.draft')}</span>
)}
<div className="grid grid-cols-3 gap-2.5 mt-auto pt-3.5 border-t border-zinc-100 dark:border-zinc-800" style={{ marginTop: j.subtitle ? 14 : 'auto' }}>
{[
{ val: entryCount, label: t('journey.stats.entries') },
{ val: photoCount, label: t('journey.stats.photos') },
{ val: cityCount, label: t('journey.stats.cities') },
].map(s => (
<div key={s.label} className="flex flex-col gap-1">
<span className={`text-[16px] font-bold leading-none tracking-[-0.01em] ${s.val > 0 ? 'text-zinc-900 dark:text-white' : 'text-zinc-300 dark:text-zinc-600'}`}>
{s.val > 0 ? s.val : '--'}
</span>
<span className="text-[9px] uppercase tracking-[0.06em] text-zinc-500 font-medium">{s.label}</span>
</div>
))}
</div>
</div>
</div>
)
}
+347
View File
@@ -0,0 +1,347 @@
import { useEffect, useState, useMemo } from 'react'
import { useParams } from 'react-router-dom'
import { journeyApi } from '../api/client'
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
import { useSettingsStore } from '../store/settingsStore'
import { List, Grid, MapPin, Camera, BookOpen, Image } from 'lucide-react'
import JourneyMap from '../components/Journey/JourneyMap'
import JournalBody from '../components/Journey/JournalBody'
import PhotoLightbox from '../components/Journey/PhotoLightbox'
interface PublicEntry {
id: number
title?: string | null
story?: string | null
entry_date: string
entry_time?: string | null
location_name?: string | null
location_lat?: number | null
location_lng?: number | null
mood?: string | null
weather?: string | null
pros_cons?: { pros: string[]; cons: string[] } | null
photos: PublicPhoto[]
}
interface PublicPhoto {
id: number
entry_id: number
provider: string
asset_id?: string | null
owner_id?: number | null
file_path?: string | null
caption?: string | null
}
function photoUrl(p: PublicPhoto, shareToken: string): string {
if (p.provider === 'local') return `/api/public/journey/${shareToken}/photo/local/${encodeURIComponent(p.file_path || '')}/0/original`
return `/api/public/journey/${shareToken}/photo/${p.provider}/${p.asset_id}/${p.owner_id}/original`
}
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 groupByDate(entries: PublicEntry[]): Map<string, PublicEntry[]> {
const groups = new Map<string, PublicEntry[]>()
for (const e of entries) {
const d = e.entry_date
if (!groups.has(d)) groups.set(d, [])
groups.get(d)!.push(e)
}
return groups
}
export default function JourneyPublicPage() {
const { token } = useParams()
const [data, setData] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
const [lightbox, setLightbox] = useState<{ photos: { id: string; src: string; caption?: string | null }[]; index: number } | null>(null)
const { t } = useTranslation()
const [showLangPicker, setShowLangPicker] = useState(false)
const locale = useSettingsStore(s => s.settings.language) || 'en'
useEffect(() => {
if (!token) return
journeyApi.getPublicJourney(token)
.then(d => setData(d))
.catch(() => setError(true))
.finally(() => setLoading(false))
}, [token])
const entries = (data?.entries || []) as PublicEntry[]
const perms = data?.permissions || {}
const journey = data?.journey || {}
const stats = data?.stats || {}
const groupedEntries = useMemo(() => groupByDate(entries), [entries])
const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries])
const mapEntries = useMemo(() => entries.filter(e => e.location_lat && e.location_lng), [entries])
const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries])
// Set default view based on permissions
useEffect(() => {
if (!perms.share_timeline && perms.share_gallery) setView('gallery')
else if (!perms.share_timeline && !perms.share_gallery && perms.share_map) setView('map')
}, [perms])
if (loading) {
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
</div>
)
}
if (error || !data) {
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white mb-2">{t('journey.public.notFound')}</h1>
<p className="text-zinc-500">{t('journey.public.notFoundMessage')}</p>
</div>
</div>
)
}
const availableViews = [
perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[]
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
{/* Hero */}
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px' }}>
{/* Cover image background */}
{journey.cover_image && (
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
)}
{/* Decorative circles */}
<div style={{ position: 'absolute', top: -60, right: -60, width: 200, height: 200, borderRadius: '50%', background: 'rgba(255,255,255,0.03)' }} />
<div style={{ position: 'absolute', bottom: -40, left: -40, width: 150, height: 150, borderRadius: '50%', background: 'rgba(255,255,255,0.02)' }} />
{/* Language picker */}
<div style={{ position: 'absolute', top: 12, right: 12, zIndex: 10 }}>
<button onClick={() => setShowLangPicker(v => !v)} style={{
padding: '5px 12px', borderRadius: 20, border: '1px solid rgba(255,255,255,0.15)',
background: 'rgba(255,255,255,0.1)', backdropFilter: 'blur(8px)',
color: 'rgba(255,255,255,0.7)', fontSize: 11, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
}}>
{SUPPORTED_LANGUAGES.find(l => l.value === (locale?.split('-')[0] || 'en'))?.label || 'Language'}
</button>
{showLangPicker && (
<div style={{ position: 'absolute', top: '100%', right: 0, marginTop: 6, background: 'white', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.2)', padding: 4, zIndex: 50, minWidth: 150 }}>
{SUPPORTED_LANGUAGES.map(lang => (
<button key={lang.value} onClick={() => {
useSettingsStore.setState(s => ({ settings: { ...s.settings, language: lang.value } }))
setShowLangPicker(false)
}}
style={{ display: 'block', width: '100%', padding: '6px 12px', border: 'none', background: 'none', textAlign: 'left', cursor: 'pointer', fontSize: 12, color: '#374151', borderRadius: 6, fontFamily: 'inherit' }}
onMouseEnter={e => e.currentTarget.style.background = '#f3f4f6'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
>{lang.label}</button>
))}
</div>
)}
</div>
{/* Logo */}
<div style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 44, height: 44, borderRadius: 12, background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(8px)', marginBottom: 12, border: '1px solid rgba(255,255,255,0.1)', position: 'relative' }}>
<img src="/icons/icon-white.svg" alt="TREK" width={26} height={26} />
</div>
<div style={{ fontSize: 10, fontWeight: 600, letterSpacing: 3, textTransform: 'uppercase', opacity: 0.35, marginBottom: 12, position: 'relative' }}>{t('journey.public.tagline')}</div>
<h1 className="relative" style={{ margin: '0 0 4px', fontSize: 26, fontWeight: 700, letterSpacing: -0.5 }}>{journey.title}</h1>
{journey.subtitle && (
<div className="relative" style={{ fontSize: 13, opacity: 0.5, maxWidth: 400, margin: '0 auto', lineHeight: 1.5 }}>{journey.subtitle}</div>
)}
{/* Stats pill */}
<div className="relative" style={{ marginTop: 12, display: 'inline-flex', alignItems: 'center', gap: 12, padding: '8px 18px', borderRadius: 20, background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(4px)', border: '1px solid rgba(255,255,255,0.08)' }}>
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><BookOpen size={12} /> {stats.entries} {t('journey.stats.entries')}</span>
<span style={{ fontSize: 11, opacity: 0.4 }}>·</span>
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><Camera size={12} /> {stats.photos} {t('journey.stats.photos')}</span>
<span style={{ fontSize: 11, opacity: 0.4 }}>·</span>
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><MapPin size={12} /> {stats.cities} {t('journey.stats.places')}</span>
</div>
<div className="relative" style={{ marginTop: 12, fontSize: 9, fontWeight: 500, letterSpacing: 1.5, textTransform: 'uppercase', opacity: 0.25 }}>{t('journey.public.readOnly')}</div>
</div>
{/* Content */}
<div className="max-w-[900px] mx-auto px-4 md:px-8 py-6">
{/* View tabs */}
{availableViews.length > 1 && (
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden mb-6 w-fit">
{availableViews.map(v => (
<button
key={v.id}
onClick={() => setView(v.id)}
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
view === v.id
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
<v.icon size={13} />
{v.label}
</button>
))}
</div>
)}
{/* Timeline */}
{view === 'timeline' && perms.share_timeline && (
<div className="flex flex-col gap-6">
{sortedDates.map(date => {
const dayEntries = groupedEntries.get(date)!
const fd = formatDate(date)
return (
<div key={date}>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[14px] font-bold">{fd.day}</div>
<div>
<div className="text-[14px] font-semibold text-zinc-900 dark:text-white">{fd.weekday}</div>
<div className="text-[11px] text-zinc-500">{fd.month} {fd.day}</div>
</div>
</div>
<div className="flex flex-col gap-4 pl-[52px]">
{dayEntries.map(entry => (
<div key={entry.id} className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden">
{entry.photos.length > 0 && (
<div className="relative">
<img
src={photoUrl(entry.photos[0], token!)}
className="w-full h-52 object-cover cursor-pointer"
alt=""
onClick={() => setLightbox({ photos: entry.photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!), caption: p.caption })), index: 0 })}
/>
{entry.photos.length > 1 && (
<div className="absolute bottom-2 right-2 bg-black/60 backdrop-blur text-white rounded-full px-2 py-0.5 text-[10px] font-semibold flex items-center gap-1">
<Image size={10} /> +{entry.photos.length - 1}
</div>
)}
{entry.title && (
<div className="absolute inset-x-0 bottom-0 p-4" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.5) 0%, transparent 100%)' }}>
<h3 className="text-[18px] font-bold text-white drop-shadow-sm">{entry.title}</h3>
</div>
)}
</div>
)}
<div className="px-5 py-4">
{!entry.photos.length && entry.title && (
<h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white mb-1">{entry.title}</h3>
)}
{entry.location_name && (
<div className="flex items-center gap-1.5 text-[11px] text-zinc-500 mb-2">
<MapPin size={11} /> {entry.location_name}
</div>
)}
{entry.story && (
<div className="text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed">
<JournalBody text={entry.story} />
</div>
)}
{entry.pros_cons && ((entry.pros_cons.pros?.length ?? 0) > 0 || (entry.pros_cons.cons?.length ?? 0) > 0) && (
<div className="grid grid-cols-2 gap-3 mt-4">
{(entry.pros_cons.pros?.length ?? 0) > 0 && (
<div className="rounded-xl border border-green-200 dark:border-green-800/30 p-3" style={{ background: 'linear-gradient(180deg, #F0FDF4 0%, white 100%)' }}>
<div className="text-[10px] font-bold uppercase tracking-wide text-green-700 mb-2">{t('journey.editor.pros')}</div>
{entry.pros_cons.pros!.map((p, i) => (
<div key={i} className="flex items-start gap-1.5 text-[12px] text-green-900 mb-1">
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0 mt-[6px]" />{p}
</div>
))}
</div>
)}
{(entry.pros_cons.cons?.length ?? 0) > 0 && (
<div className="rounded-xl border border-red-200 dark:border-red-800/30 p-3" style={{ background: 'linear-gradient(180deg, #FEF2F2 0%, white 100%)' }}>
<div className="text-[10px] font-bold uppercase tracking-wide text-red-700 mb-2">{t('journey.editor.cons')}</div>
{entry.pros_cons.cons!.map((c, i) => (
<div key={i} className="flex items-start gap-1.5 text-[12px] text-red-900 mb-1">
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0 mt-[6px]" />{c}
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
))}
</div>
</div>
)
})}
</div>
)}
{/* Gallery */}
{view === 'gallery' && perms.share_gallery && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
{allPhotos.map(({ photo }, idx) => (
<div
key={photo.id}
className="aspect-square rounded-lg overflow-hidden cursor-pointer"
onClick={() => setLightbox({ photos: allPhotos.map(({ photo: p }) => ({ id: String(p.id), src: photoUrl(p, token!), caption: p.caption })), index: idx })}
>
<img src={photoUrl(photo, token!)} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
</div>
))}
</div>
)}
{/* Map */}
{view === 'map' && perms.share_map && (
<div className="rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-700">
<JourneyMap
checkins={[]}
entries={mapEntries.map(e => ({
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={500}
/>
</div>
)}
</div>
{/* Powered by */}
<div className="flex flex-col items-center py-8 gap-2">
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 16px', borderRadius: 20, background: 'white', border: '1px solid #e5e7eb', boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
<img src="/icons/icon.svg" alt="TREK" width={18} height={18} style={{ borderRadius: 4 }} />
<span style={{ fontSize: 11, color: '#9ca3af' }}>{t('journey.public.sharedVia')} <strong style={{ color: '#6b7280' }}>TREK</strong></span>
</div>
<div style={{ fontSize: 10, color: '#d1d5db' }}>
Made with <span style={{ color: '#ef4444' }}></span> by Maurice · <a href="https://github.com/mauriceboe/TREK" style={{ color: '#9ca3af', textDecoration: 'none' }}>GitHub</a>
</div>
</div>
{/* Lightbox */}
{lightbox && (
<PhotoLightbox
photos={lightbox.photos}
startIndex={lightbox.index}
onClose={() => setLightbox(null)}
/>
)}
</div>
)
}
+5 -14
View File
@@ -14,7 +14,7 @@ import PlaceFormModal from '../components/Planner/PlaceFormModal'
import TripFormModal from '../components/Trips/TripFormModal' import TripFormModal from '../components/Trips/TripFormModal'
import TripMembersModal from '../components/Trips/TripMembersModal' import TripMembersModal from '../components/Trips/TripMembersModal'
import { ReservationModal } from '../components/Planner/ReservationModal' import { ReservationModal } from '../components/Planner/ReservationModal'
import MemoriesPanel from '../components/Memories/MemoriesPanel' // MemoriesPanel moved to Journey addon
import ReservationsPanel from '../components/Planner/ReservationsPanel' import ReservationsPanel from '../components/Planner/ReservationsPanel'
import PackingListPanel from '../components/Packing/PackingListPanel' import PackingListPanel from '../components/Packing/PackingListPanel'
import TodoListPanel from '../components/Todo/TodoListPanel' import TodoListPanel from '../components/Todo/TodoListPanel'
@@ -23,7 +23,7 @@ import BudgetPanel from '../components/Budget/BudgetPanel'
import CollabPanel from '../components/Collab/CollabPanel' import CollabPanel from '../components/Collab/CollabPanel'
import Navbar from '../components/Layout/Navbar' import Navbar from '../components/Layout/Navbar'
import { useToast } from '../components/shared/Toast' import { useToast } from '../components/shared/Toast'
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, PackageCheck, Wallet, FolderOpen, Camera, Users } from 'lucide-react' import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, PackageCheck, Wallet, FolderOpen, Users } from 'lucide-react'
import { useTranslation } from '../i18n' import { useTranslation } from '../i18n'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client' import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client'
import ConfirmDialog from '../components/shared/ConfirmDialog' import ConfirmDialog from '../components/shared/ConfirmDialog'
@@ -97,7 +97,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
toast.info(t('undo.done', { action: label ?? '' })) toast.info(t('undo.done', { action: label ?? '' }))
}, [undo, lastActionLabel, toast]) }, [undo, lastActionLabel, toast])
const [enabledAddons, setEnabledAddons] = useState<Record<string, boolean>>({ packing: true, budget: true, documents: true }) const [enabledAddons, setEnabledAddons] = useState<Record<string, boolean>>({ packing: true, budget: true, documents: true, collab: false })
const [tripAccommodations, setTripAccommodations] = useState<Accommodation[]>([]) const [tripAccommodations, setTripAccommodations] = useState<Accommodation[]>([])
const [allowedFileTypes, setAllowedFileTypes] = useState<string | null>(null) const [allowedFileTypes, setAllowedFileTypes] = useState<string | null>(null)
const [tripMembers, setTripMembers] = useState<TripMember[]>([]) const [tripMembers, setTripMembers] = useState<TripMember[]>([])
@@ -113,9 +113,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
addonsApi.enabled().then(data => { addonsApi.enabled().then(data => {
const map = {} const map = {}
data.addons.forEach(a => { map[a.id] = true }) data.addons.forEach(a => { map[a.id] = true })
// Check if any photo provider is enabled (for memories tab to show) setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab })
const hasPhotoProviders = data.addons.some(a => a.type === 'photo_provider')
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, memories: hasPhotoProviders })
}).catch(() => {}) }).catch(() => {})
authApi.getAppConfig().then(config => { authApi.getAppConfig().then(config => {
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types) if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
@@ -128,7 +126,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
...(enabledAddons.packing ? [{ id: 'listen', label: t('trip.tabs.lists'), shortLabel: t('trip.tabs.listsShort'), icon: PackageCheck }] : []), ...(enabledAddons.packing ? [{ id: 'listen', label: t('trip.tabs.lists'), shortLabel: t('trip.tabs.listsShort'), icon: PackageCheck }] : []),
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget'), icon: Wallet }] : []), ...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget'), icon: Wallet }] : []),
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files'), icon: FolderOpen }] : []), ...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files'), icon: FolderOpen }] : []),
...(enabledAddons.memories ? [{ id: 'memories', label: t('memories.title'), icon: Camera }] : []),
...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name'), icon: Users }] : []), ...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name'), icon: Users }] : []),
] ]
@@ -890,7 +887,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div> </div>
<div style={{ flex: 1, overflow: 'auto' }}> <div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left' {mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} /> ? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} /> : <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
} }
</div> </div>
@@ -946,12 +943,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div> </div>
)} )}
{activeTab === 'memories' && (
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
<MemoriesPanel tripId={Number(tripId)} startDate={trip?.start_date || null} endDate={trip?.end_date || null} />
</div>
)}
{activeTab === 'collab' && ( {activeTab === 'collab' && (
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}> <div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
<CollabPanel tripId={tripId} tripMembers={tripMembers} /> <CollabPanel tripId={tripId} tripMembers={tripMembers} />
+213
View File
@@ -0,0 +1,213 @@
import { create } from 'zustand'
import { journeyApi } from '../api/client'
export interface Journey {
id: number
user_id: number
title: string
subtitle?: string | null
cover_gradient?: string | null
cover_image?: string | null
status: 'draft' | 'active' | 'completed'
created_at: number
updated_at: number
}
export interface JourneyEntry {
id: number
journey_id: number
source_trip_id?: number | null
source_place_id?: number | null
source_trip_name?: string | null
author_id: number
type: 'entry' | 'checkin' | 'skeleton'
title?: string | null
story?: string | null
entry_date: string
entry_time?: string | null
location_name?: string | null
location_lat?: number | null
location_lng?: number | null
mood?: string | null
weather?: string | null
tags?: string[]
pros_cons?: { pros: string[]; cons: string[] } | null
visibility: string
sort_order: number
photos: JourneyPhoto[]
created_at: number
updated_at: number
}
export interface JourneyPhoto {
id: number
entry_id: number
provider: 'local' | 'immich' | 'synologyphotos'
asset_id?: string | null
owner_id?: number | null
file_path?: string | null
thumbnail_path?: string | null
caption?: string | null
sort_order: number
width?: number | null
height?: number | null
shared: number
created_at: number
}
export interface JourneyTrip {
trip_id: number
added_at: number
title: string
start_date?: string | null
end_date?: string | null
cover_image?: string | null
currency?: string
place_count: number
}
export interface JourneyContributor {
journey_id: number
user_id: number
role: 'owner' | 'editor' | 'viewer'
added_at: number
username: string
avatar?: string | null
}
export interface JourneyDetail extends Journey {
entries: JourneyEntry[]
trips: JourneyTrip[]
contributors: JourneyContributor[]
stats: { entries: number; photos: number; cities: number }
}
interface JourneyState {
journeys: Journey[]
current: JourneyDetail | null
loading: boolean
loadJourneys: () => Promise<void>
loadJourney: (id: number) => Promise<void>
createJourney: (data: { title: string; subtitle?: string; trip_ids?: number[] }) => Promise<Journey>
updateJourney: (id: number, data: Record<string, unknown>) => Promise<void>
deleteJourney: (id: number) => Promise<void>
createEntry: (journeyId: number, data: Record<string, unknown>) => Promise<JourneyEntry>
updateEntry: (entryId: number, data: Record<string, unknown>) => Promise<void>
deleteEntry: (entryId: number) => Promise<void>
uploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
deletePhoto: (photoId: number) => Promise<void>
clear: () => void
}
export const useJourneyStore = create<JourneyState>((set, get) => ({
journeys: [],
current: null,
loading: false,
loadJourneys: async () => {
set({ loading: true })
try {
const data = await journeyApi.list()
set({ journeys: data.journeys || [] })
} finally {
set({ loading: false })
}
},
loadJourney: async (id) => {
set({ loading: true })
try {
const data = await journeyApi.get(id)
set({ current: data })
} finally {
set({ loading: false })
}
},
createJourney: async (data) => {
const journey = await journeyApi.create(data)
set(s => ({ journeys: [journey, ...s.journeys] }))
return journey
},
updateJourney: async (id, data) => {
const updated = await journeyApi.update(id, data)
set(s => ({
journeys: s.journeys.map(j => j.id === id ? { ...j, ...updated } : j),
current: s.current?.id === id ? { ...s.current, ...updated } : s.current,
}))
},
deleteJourney: async (id) => {
await journeyApi.delete(id)
set(s => ({
journeys: s.journeys.filter(j => j.id !== id),
current: s.current?.id === id ? null : s.current,
}))
},
createEntry: async (journeyId, data) => {
const entry = await journeyApi.createEntry(journeyId, data)
entry.photos = entry.photos || []
set(s => {
if (s.current?.id !== journeyId) return s
return { current: { ...s.current, entries: [...s.current.entries, entry] } }
})
return entry
},
updateEntry: async (entryId, data) => {
const updated = await journeyApi.updateEntry(entryId, data)
set(s => {
if (!s.current) return s
return { current: { ...s.current, entries: s.current.entries.map(e => e.id === entryId ? { ...e, ...updated } : e) } }
})
},
deleteEntry: async (entryId) => {
await journeyApi.deleteEntry(entryId)
set(s => {
if (!s.current) return s
return { current: { ...s.current, entries: s.current.entries.filter(e => e.id !== entryId) } }
})
},
uploadPhotos: async (entryId, formData) => {
const data = await journeyApi.uploadPhotos(entryId, formData)
const photos = data.photos || []
set(s => {
if (!s.current) return s
return {
current: {
...s.current,
entries: s.current.entries.map(e =>
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
),
},
}
})
return photos
},
deletePhoto: async (photoId) => {
await journeyApi.deletePhoto(photoId)
set(s => {
if (!s.current) return s
return {
current: {
...s.current,
entries: s.current.entries.map(e => ({
...e,
photos: (e.photos || []).filter(p => p.id !== photoId),
})),
},
}
})
},
clear: () => set({ journeys: [], current: null, loading: false }),
}))
+6 -1
View File
@@ -37,6 +37,8 @@ import atlasRoutes from './routes/atlas';
import memoriesRoutes from './routes/memories/unified'; import memoriesRoutes from './routes/memories/unified';
import notificationRoutes from './routes/notifications'; import notificationRoutes from './routes/notifications';
import shareRoutes from './routes/share'; import shareRoutes from './routes/share';
import journeyRoutes from './routes/journey';
import journeyPublicRoutes from './routes/journeyPublic';
import { mcpHandler } from './mcp'; import { mcpHandler } from './mcp';
import { Addon } from './types'; import { Addon } from './types';
import { getPhotoProviderConfig } from './services/memories/helpersService'; import { getPhotoProviderConfig } from './services/memories/helpersService';
@@ -142,9 +144,10 @@ export function createApp(): express.Application {
}); });
} }
// Static: avatars and covers are public // Static: avatars, covers, and journey photos
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars'))); app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers'))); app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers')));
app.use('/uploads/journey', express.static(path.join(__dirname, '../uploads/journey')));
// Photos require auth or valid share token // Photos require auth or valid share token
app.get('/uploads/photos/:filename', (req: Request, res: Response) => { app.get('/uploads/photos/:filename', (req: Request, res: Response) => {
@@ -256,6 +259,8 @@ export function createApp(): express.Application {
// Addon routes // Addon routes
app.use('/api/addons/vacay', vacayRoutes); app.use('/api/addons/vacay', vacayRoutes);
app.use('/api/addons/atlas', atlasRoutes); app.use('/api/addons/atlas', atlasRoutes);
app.use('/api/journeys', journeyRoutes);
app.use('/api/public/journey', journeyPublicRoutes);
app.use('/api/integrations/memories', memoriesRoutes); app.use('/api/integrations/memories', memoriesRoutes);
app.use('/api/maps', mapsRoutes); app.use('/api/maps', mapsRoutes);
app.use('/api/weather', weatherRoutes); app.use('/api/weather', weatherRoutes);
+432
View File
@@ -884,6 +884,438 @@ function runMigrations(db: Database.Database): void {
ins.run(r.trip_id, r.category, idx++); ins.run(r.trip_id, r.category, idx++);
} }
}, },
// Migration 84: Journey addon — trip tracking & travel journal
() => {
// Register addon (disabled by default — opt-in)
db.prepare(`
INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, config, sort_order)
VALUES ('journey', 'Journey', 'Trip tracking & travel journal — check-ins, photos, daily stories', 'global', 'Compass', 0, '{}', 35)
`).run();
// Core journey table
db.exec(`
CREATE TABLE IF NOT EXISTS journeys (
id TEXT PRIMARY KEY,
trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
cover_image TEXT,
status TEXT NOT NULL DEFAULT 'draft',
started_at TEXT,
ended_at TEXT,
is_public INTEGER NOT NULL DEFAULT 0,
public_token TEXT UNIQUE,
settings TEXT DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
)
`);
// Check-ins — visited locations
db.exec(`
CREATE TABLE IF NOT EXISTS journey_checkins (
id TEXT PRIMARY KEY,
journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
name TEXT NOT NULL,
lat REAL,
lng REAL,
address TEXT,
country_code TEXT,
notes TEXT,
checked_in_at TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'manual',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
)
`);
// Journal entries — daily stories
db.exec(`
CREATE TABLE IF NOT EXISTS journey_entries (
id TEXT PRIMARY KEY,
journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
checkin_id TEXT REFERENCES journey_checkins(id) ON DELETE SET NULL,
entry_date TEXT NOT NULL,
title TEXT,
body TEXT,
mood TEXT,
weather TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
)
`);
// Photos — local uploads + provider references (Immich/Synology)
db.exec(`
CREATE TABLE IF NOT EXISTS journey_photos (
id TEXT PRIMARY KEY,
journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
checkin_id TEXT REFERENCES journey_checkins(id) ON DELETE SET NULL,
entry_id TEXT REFERENCES journey_entries(id) ON DELETE SET NULL,
storage_type TEXT NOT NULL DEFAULT 'local',
asset_id TEXT,
file_path TEXT,
thumbnail_path TEXT,
original_name TEXT,
mime_type TEXT,
size_bytes INTEGER,
caption TEXT,
taken_at TEXT,
lat REAL,
lng REAL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
)
`);
// GPS trail points (Dawarich integration)
db.exec(`
CREATE TABLE IF NOT EXISTS journey_location_trail (
id INTEGER PRIMARY KEY AUTOINCREMENT,
journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
lat REAL NOT NULL,
lng REAL NOT NULL,
altitude REAL,
accuracy REAL,
recorded_at TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'dawarich'
)
`);
// Indexes
db.exec(`
CREATE INDEX IF NOT EXISTS idx_journeys_user ON journeys(user_id);
CREATE INDEX IF NOT EXISTS idx_journeys_trip ON journeys(trip_id);
CREATE INDEX IF NOT EXISTS idx_journeys_public_token ON journeys(public_token);
CREATE INDEX IF NOT EXISTS idx_journey_checkins_journey ON journey_checkins(journey_id, checked_in_at);
CREATE INDEX IF NOT EXISTS idx_journey_entries_journey_date ON journey_entries(journey_id, entry_date);
CREATE INDEX IF NOT EXISTS idx_journey_photos_journey ON journey_photos(journey_id);
CREATE INDEX IF NOT EXISTS idx_journey_photos_checkin ON journey_photos(checkin_id);
CREATE INDEX IF NOT EXISTS idx_journey_photos_entry ON journey_photos(entry_id);
CREATE INDEX IF NOT EXISTS idx_journey_trail_journey_time ON journey_location_trail(journey_id, recorded_at);
`);
},
// Migration 85: Journal — richer entry fields for magazine-style design
() => {
// Highlight tags (JSON array), visibility control, hero photo, color accent
try { db.exec('ALTER TABLE journey_entries ADD COLUMN highlight_tags TEXT'); } catch {}
try { db.exec("ALTER TABLE journey_entries ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private'"); } catch {}
try { db.exec('ALTER TABLE journey_entries ADD COLUMN hero_photo_id TEXT'); } catch {}
try { db.exec('ALTER TABLE journey_entries ADD COLUMN color_accent TEXT'); } catch {}
try { db.exec('ALTER TABLE journey_entries ADD COLUMN place_name TEXT'); } catch {}
try { db.exec('ALTER TABLE journey_entries ADD COLUMN place_id INTEGER REFERENCES places(id) ON DELETE SET NULL'); } catch {}
try { db.exec('ALTER TABLE journey_entries ADD COLUMN lat REAL'); } catch {}
try { db.exec('ALTER TABLE journey_entries ADD COLUMN lng REAL'); } catch {}
// Check-in: allow a single cover photo reference
try { db.exec('ALTER TABLE journey_checkins ADD COLUMN photo_id TEXT'); } catch {}
// Photos: add caption edit timestamp for gallery ordering
try { db.exec('ALTER TABLE journey_photos ADD COLUMN width INTEGER'); } catch {}
try { db.exec('ALTER TABLE journey_photos ADD COLUMN height INTEGER'); } catch {}
},
// Migration 86: Journey multi-trip support + sharing/collaboration
() => {
// Junction table: journey can include multiple trips
db.exec(`
CREATE TABLE IF NOT EXISTS journey_trips (
journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
PRIMARY KEY (journey_id, trip_id)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_trips_journey ON journey_trips(journey_id)');
// Sharing: invite users to a journey
db.exec(`
CREATE TABLE IF NOT EXISTS journey_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'viewer',
invited_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE(journey_id, user_id)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_members_user ON journey_members(user_id)');
// author tracking on entries and checkins
try { db.exec('ALTER TABLE journey_entries ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch {}
try { db.exec('ALTER TABLE journey_checkins ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch {}
},
// Migration 87: Journey rebuild — new schema with trip sync
() => {
// Migrate existing data from old tables into backup, then rebuild
const hasOldJourneys = db.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='journeys'"
).get();
let oldJourneys: any[] = [];
let oldEntries: any[] = [];
let oldPhotos: any[] = [];
if (hasOldJourneys) {
// Save existing data before dropping
try { oldJourneys = db.prepare('SELECT * FROM journeys').all(); } catch {}
try { oldEntries = db.prepare('SELECT * FROM journey_entries').all(); } catch {}
try { oldPhotos = db.prepare('SELECT * FROM journey_photos').all(); } catch {}
// Drop all old journey tables
db.exec('DROP TABLE IF EXISTS journey_location_trail');
db.exec('DROP TABLE IF EXISTS journey_photos');
db.exec('DROP TABLE IF EXISTS journey_entries');
db.exec('DROP TABLE IF EXISTS journey_checkins');
db.exec('DROP TABLE IF EXISTS journey_members');
db.exec('DROP TABLE IF EXISTS journey_trips');
db.exec('DROP TABLE IF EXISTS journeys');
}
// New schema
db.exec(`
CREATE TABLE journeys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
subtitle TEXT,
cover_gradient TEXT,
status TEXT DEFAULT 'draft',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
db.exec(`
CREATE TABLE journey_trips (
journey_id INTEGER NOT NULL,
trip_id INTEGER NOT NULL,
added_at INTEGER NOT NULL,
PRIMARY KEY (journey_id, trip_id),
FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE,
FOREIGN KEY (trip_id) REFERENCES trips(id) ON DELETE CASCADE
)
`);
db.exec(`
CREATE TABLE journey_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
journey_id INTEGER NOT NULL,
source_trip_id INTEGER,
source_place_id INTEGER,
author_id INTEGER NOT NULL,
type TEXT NOT NULL,
title TEXT,
story TEXT,
entry_date TEXT NOT NULL,
entry_time TEXT,
location_name TEXT,
location_lat REAL,
location_lng REAL,
mood TEXT,
weather TEXT,
tags TEXT,
visibility TEXT DEFAULT 'private',
sort_order INTEGER DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE,
FOREIGN KEY (source_trip_id) REFERENCES trips(id) ON DELETE SET NULL,
FOREIGN KEY (source_place_id) REFERENCES places(id) ON DELETE SET NULL,
FOREIGN KEY (author_id) REFERENCES users(id)
)
`);
db.exec(`
CREATE TABLE journey_photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entry_id INTEGER NOT NULL,
file_path TEXT NOT NULL,
thumbnail_path TEXT,
caption TEXT,
sort_order INTEGER DEFAULT 0,
width INTEGER,
height INTEGER,
created_at INTEGER NOT NULL,
FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE
)
`);
db.exec(`
CREATE TABLE journey_contributors (
journey_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
role TEXT NOT NULL,
added_at INTEGER NOT NULL,
PRIMARY KEY (journey_id, user_id),
FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
// Indexes
db.exec(`
CREATE INDEX idx_journeys_user ON journeys(user_id);
CREATE INDEX idx_journey_entries_journey ON journey_entries(journey_id, entry_date);
CREATE INDEX idx_journey_entries_source ON journey_entries(source_place_id);
CREATE INDEX idx_journey_photos_entry ON journey_photos(entry_id);
CREATE INDEX idx_journey_trips_journey ON journey_trips(journey_id);
CREATE INDEX idx_journey_contributors_user ON journey_contributors(user_id);
`);
// Re-import old data if it existed
if (oldJourneys.length > 0) {
const ts = Date.now();
const journeyIdMap = new Map<string, number>(); // old TEXT id -> new INTEGER id
for (const j of oldJourneys) {
const res = db.prepare(`
INSERT INTO journeys (user_id, title, subtitle, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
j.user_id,
j.title || 'Untitled Journey',
j.description || null,
j.status || 'draft',
j.created_at ? new Date(j.created_at).getTime() : ts,
j.updated_at ? new Date(j.updated_at).getTime() : ts
);
journeyIdMap.set(j.id, Number(res.lastInsertRowid));
// Add owner as contributor
db.prepare(`
INSERT OR IGNORE INTO journey_contributors (journey_id, user_id, role, added_at)
VALUES (?, ?, 'owner', ?)
`).run(Number(res.lastInsertRowid), j.user_id, ts);
// Link trip if old journey had one
if (j.trip_id) {
try {
db.prepare(`
INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at)
VALUES (?, ?, ?)
`).run(Number(res.lastInsertRowid), j.trip_id, ts);
} catch {}
}
}
// Migrate entries
const entryIdMap = new Map<string, number>();
for (const e of oldEntries) {
const newJourneyId = journeyIdMap.get(e.journey_id);
if (!newJourneyId) continue;
const res = db.prepare(`
INSERT INTO journey_entries (journey_id, author_id, type, title, story, entry_date, entry_time, location_name, location_lat, location_lng, mood, weather, visibility, sort_order, created_at, updated_at)
VALUES (?, ?, 'entry', ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
newJourneyId,
e.user_id || oldJourneys.find((j: any) => j.id === e.journey_id)?.user_id || 1,
e.title || null,
e.body || null,
e.entry_date || new Date().toISOString().split('T')[0],
e.place_name || null,
e.lat || null,
e.lng || null,
e.mood || null,
e.weather || null,
e.visibility || 'private',
e.sort_order || 0,
e.created_at ? new Date(e.created_at).getTime() : ts,
e.updated_at ? new Date(e.updated_at).getTime() : ts
);
entryIdMap.set(e.id, Number(res.lastInsertRowid));
}
// Migrate photos
for (const p of oldPhotos) {
const newEntryId = p.entry_id ? entryIdMap.get(p.entry_id) : null;
if (!newEntryId || !p.file_path) continue;
db.prepare(`
INSERT INTO journey_photos (entry_id, file_path, thumbnail_path, caption, sort_order, width, height, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
newEntryId,
p.file_path,
p.thumbnail_path || null,
p.caption || null,
p.sort_order || 0,
p.width || null,
p.height || null,
p.created_at ? new Date(p.created_at).getTime() : ts
);
}
console.log(`[DB] Journey migration: imported ${journeyIdMap.size} journeys, ${entryIdMap.size} entries, photos migrated`);
}
},
// Migration 88: Journey photos — provider support (Immich/Synology)
() => {
try { db.exec("ALTER TABLE journey_photos ADD COLUMN provider TEXT NOT NULL DEFAULT 'local'"); } catch {}
try { db.exec('ALTER TABLE journey_photos ADD COLUMN asset_id TEXT'); } catch {}
try { db.exec('ALTER TABLE journey_photos ADD COLUMN owner_id INTEGER REFERENCES users(id)'); } catch {}
try { db.exec('ALTER TABLE journey_photos ADD COLUMN shared INTEGER NOT NULL DEFAULT 1'); } catch {}
// file_path was NOT NULL — recreate table to make it nullable
const hasProvider = db.prepare("SELECT 1 FROM pragma_table_info('journey_photos') WHERE name = 'provider'").get();
if (hasProvider) {
// Already has the column, just ensure file_path is nullable by recreating
try {
db.exec(`
CREATE TABLE journey_photos_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entry_id INTEGER NOT NULL,
provider TEXT NOT NULL DEFAULT 'local',
asset_id TEXT,
owner_id INTEGER REFERENCES users(id),
file_path TEXT,
thumbnail_path TEXT,
caption TEXT,
sort_order INTEGER DEFAULT 0,
width INTEGER,
height INTEGER,
shared INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE
);
INSERT INTO journey_photos_new SELECT id, entry_id, provider, asset_id, owner_id, file_path, thumbnail_path, caption, sort_order, width, height, shared, created_at FROM journey_photos;
DROP TABLE journey_photos;
ALTER TABLE journey_photos_new RENAME TO journey_photos;
CREATE INDEX idx_journey_photos_entry ON journey_photos(entry_id);
`);
} catch {}
}
},
// Migration 89: Journey cover image
() => {
try { db.exec('ALTER TABLE journeys ADD COLUMN cover_image TEXT'); } catch {}
},
// Migration 90: Pros/Cons for journey entries
() => {
try { db.exec('ALTER TABLE journey_entries ADD COLUMN pros_cons TEXT'); } catch {}
},
// Migration 91: Journey share tokens
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS journey_share_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
journey_id INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
created_by INTEGER NOT NULL,
share_timeline INTEGER DEFAULT 1,
share_gallery INTEGER DEFAULT 1,
share_map INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id)
)
`);
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_journey_share_journey ON journey_share_tokens(journey_id)');
},
]; ];
if (currentVersion < migrations.length) { if (currentVersion < migrations.length) {
+1
View File
@@ -89,6 +89,7 @@ function seedAddons(db: Database.Database): void {
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 }, { id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
{ id: 'mcp', name: 'MCP', description: 'Model Context Protocol for AI assistant integration', type: 'integration', icon: 'Terminal', enabled: 0, sort_order: 12 }, { id: 'mcp', name: 'MCP', description: 'Model Context Protocol for AI assistant integration', type: 'integration', icon: 'Terminal', enabled: 0, sort_order: 12 },
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 }, { id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
{ id: 'journey', name: 'Journey', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 },
]; ];
const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)'); const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order); for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order);
+290
View File
@@ -0,0 +1,290 @@
import express, { Request, Response } from 'express';
import multer from 'multer';
import path from 'node:path';
import fs from 'node:fs';
import crypto from 'node:crypto';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import * as svc from '../services/journeyService';
import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService';
import { uploadToImmich } from '../services/memories/immichService';
const router = express.Router();
const uploadsBase = path.join(__dirname, '../../uploads/journey');
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
if (!fs.existsSync(uploadsBase)) fs.mkdirSync(uploadsBase, { recursive: true });
cb(null, uploadsBase);
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase() || '.jpg';
cb(null, `${crypto.randomUUID()}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 20 * 1024 * 1024 },
});
// ── Static prefix routes (MUST come before /:id) ─────────────────────────
router.get('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json({ journeys: svc.listJourneys(authReq.user.id) });
});
router.post('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { title, subtitle, trip_ids } = req.body || {};
if (!title || typeof title !== 'string' || !title.trim()) {
return res.status(400).json({ error: 'Title is required' });
}
const journey = svc.createJourney(authReq.user.id, {
title: title.trim(),
subtitle,
trip_ids: Array.isArray(trip_ids) ? trip_ids : [],
});
res.status(201).json(journey);
});
router.get('/suggestions', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json({ trips: svc.getSuggestions(authReq.user.id) });
});
router.get('/available-trips', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json({ trips: svc.listUserTrips(authReq.user.id) });
});
// ── Entries (prefix /entries — before /:id) ──────────────────────────────
router.patch('/entries/:entryId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = svc.updateEntry(Number(req.params.entryId), authReq.user.id, req.body || {});
if (!result) return res.status(404).json({ error: 'Entry not found' });
res.json(result);
});
router.delete('/entries/:entryId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!svc.deleteEntry(Number(req.params.entryId), authReq.user.id)) {
return res.status(404).json({ error: 'Entry not found' });
}
res.json({ success: true });
});
// ── Photos (prefix /photos and /entries — before /:id) ───────────────────
router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10), async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const files = req.files as Express.Multer.File[];
if (!files?.length) return res.status(400).json({ error: 'No files uploaded' });
const results: any[] = [];
for (const file of files) {
const relativePath = `journey/${file.filename}`;
const photo = svc.addPhoto(
Number(req.params.entryId),
authReq.user.id,
relativePath,
undefined,
req.body?.caption
);
if (photo) {
// sync to Immich if connected — update the same photo record
try {
const immichId = await uploadToImmich(authReq.user.id, relativePath, file.originalname);
if (immichId) {
svc.setPhotoProvider(photo.id, 'immich', immichId, authReq.user.id);
photo.provider = 'immich' as any;
photo.asset_id = immichId;
photo.owner_id = authReq.user.id;
}
} catch {}
results.push(photo);
}
}
if (!results.length) return res.status(403).json({ error: 'Not allowed' });
res.status(201).json({ photos: results });
});
router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { provider, asset_id, caption } = req.body || {};
if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' });
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption);
if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' });
res.status(201).json(photo);
});
// Link an existing photo to a (different) entry
router.post('/entries/:entryId/link-photo', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { photo_id } = req.body || {};
if (!photo_id) return res.status(400).json({ error: 'photo_id required' });
const result = svc.linkPhotoToEntry(Number(req.params.entryId), Number(photo_id), authReq.user.id);
if (!result) return res.status(403).json({ error: 'Not allowed' });
res.status(201).json(result);
});
router.patch('/photos/:photoId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = svc.updatePhoto(Number(req.params.photoId), authReq.user.id, req.body || {});
if (!result) return res.status(404).json({ error: 'Photo not found' });
res.json(result);
});
router.delete('/photos/:photoId', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const photo = svc.deletePhoto(Number(req.params.photoId), authReq.user.id);
if (!photo) return res.status(404).json({ error: 'Photo not found' });
// delete local file
if (photo.file_path) {
const fullPath = path.join(__dirname, '../../uploads', photo.file_path);
try { fs.unlinkSync(fullPath); } catch {}
}
// only delete from Immich if the photo was UPLOADED through TREK (has local file)
// photos imported from Immich (no file_path) are just references — don't touch Immich
if (photo.provider === 'immich' && photo.asset_id && photo.file_path) {
try {
const { getImmichCredentials } = await import('../services/memories/immichService');
const creds = getImmichCredentials(authReq.user.id);
if (creds) {
const { safeFetch } = await import('../utils/ssrfGuard');
await safeFetch(`${creds.immich_url}/api/assets`, {
method: 'DELETE',
headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: [photo.asset_id] }),
});
}
} catch {}
}
res.json({ success: true });
});
// ── Journeys /:id (parameterized routes AFTER static prefixes) ───────────
router.get('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const data = svc.getJourneyFull(Number(req.params.id), authReq.user.id);
if (!data) return res.status(404).json({ error: 'Journey not found' });
res.json(data);
});
router.patch('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = svc.updateJourney(Number(req.params.id), authReq.user.id, req.body || {});
if (!result) return res.status(404).json({ error: 'Journey not found' });
res.json(result);
});
router.post('/:id/cover', authenticate, upload.single('cover'), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const relativePath = `journey/${req.file.filename}`;
const result = svc.updateJourney(Number(req.params.id), authReq.user.id, { cover_image: relativePath });
if (!result) return res.status(404).json({ error: 'Journey not found' });
res.json(result);
});
router.delete('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!svc.deleteJourney(Number(req.params.id), authReq.user.id)) {
return res.status(404).json({ error: 'Journey not found' });
}
res.json({ success: true });
});
// ── Journey trips ────────────────────────────────────────────────────────
router.post('/:id/trips', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { trip_id } = req.body || {};
if (!trip_id) return res.status(400).json({ error: 'trip_id required' });
if (!svc.addTripToJourney(Number(req.params.id), trip_id, authReq.user.id)) {
return res.status(403).json({ error: 'Not allowed' });
}
res.json({ success: true });
});
router.delete('/:id/trips/:tripId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!svc.removeTripFromJourney(Number(req.params.id), Number(req.params.tripId), authReq.user.id)) {
return res.status(403).json({ error: 'Not allowed' });
}
res.json({ success: true });
});
// ── Entries under journey ────────────────────────────────────────────────
router.get('/:id/entries', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const entries = svc.listEntries(Number(req.params.id), authReq.user.id);
if (!entries) return res.status(404).json({ error: 'Journey not found' });
res.json({ entries });
});
router.post('/:id/entries', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { entry_date } = req.body || {};
if (!entry_date) return res.status(400).json({ error: 'entry_date is required' });
const entry = svc.createEntry(Number(req.params.id), authReq.user.id, req.body);
if (!entry) return res.status(404).json({ error: 'Journey not found' });
res.status(201).json(entry);
});
// ── Contributors ─────────────────────────────────────────────────────────
router.post('/:id/contributors', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { user_id, role } = req.body || {};
if (!user_id) return res.status(400).json({ error: 'user_id required' });
if (!svc.addContributor(Number(req.params.id), authReq.user.id, user_id, role || 'viewer')) {
return res.status(403).json({ error: 'Not allowed' });
}
res.status(201).json({ success: true });
});
router.patch('/:id/contributors/:userId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { role } = req.body || {};
if (!svc.updateContributorRole(Number(req.params.id), authReq.user.id, Number(req.params.userId), role)) {
return res.status(403).json({ error: 'Not allowed' });
}
res.json({ success: true });
});
router.delete('/:id/contributors/:userId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!svc.removeContributor(Number(req.params.id), authReq.user.id, Number(req.params.userId))) {
return res.status(403).json({ error: 'Not allowed' });
}
res.json({ success: true });
});
// ── Share Link ────────────────────────────────────────────────────────────
router.get('/:id/share-link', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const link = getJourneyShareLink(Number(req.params.id));
res.json({ link });
});
router.post('/:id/share-link', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { share_timeline, share_gallery, share_map } = req.body || {};
const result = createOrUpdateJourneyShareLink(Number(req.params.id), authReq.user.id, { share_timeline, share_gallery, share_map });
res.json(result);
});
router.delete('/:id/share-link', authenticate, (req: Request, res: Response) => {
deleteJourneyShareLink(Number(req.params.id));
res.json({ success: true });
});
export default router;
+50
View File
@@ -0,0 +1,50 @@
import express, { Request, Response } from 'express';
import { getPublicJourney, validateShareTokenForAsset } from '../services/journeyShareService';
import { streamImmichAsset } from '../services/memories/immichService';
import path from 'node:path';
import fs from 'node:fs';
const router = express.Router();
router.get('/:token', (req: Request, res: Response) => {
const data = getPublicJourney(req.params.token);
if (!data) return res.status(404).json({ error: 'Not found' });
res.json(data);
});
// Public photo proxy — validates share token instead of auth
router.get('/:token/photo/:provider/:assetId/:ownerId/:kind', async (req: Request, res: Response) => {
const { token, provider, assetId, ownerId, kind } = req.params;
// Validate token and that this asset belongs to the shared journey
const valid = validateShareTokenForAsset(token, assetId);
if (!valid) return res.status(404).json({ error: 'Not found' });
if (provider === 'local') {
// Local file — assetId is the file_path
const filePath = path.join(__dirname, '../../uploads/journey', assetId);
const resolved = path.resolve(filePath);
const uploadsDir = path.resolve(__dirname, '../../uploads');
if (!resolved.startsWith(uploadsDir) || !fs.existsSync(resolved)) {
return res.status(404).json({ error: 'Not found' });
}
res.set('Cache-Control', 'public, max-age=86400');
return res.sendFile(resolved);
}
// Immich/Synology — proxy through
const effectiveOwnerId = valid.ownerId || Number(ownerId);
if (provider === 'immich') {
await streamImmichAsset(res, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original', effectiveOwnerId);
} else {
// Synology or other providers — try dynamic import
try {
const { streamSynologyAsset } = await import('../services/memories/synologyService');
await streamSynologyAsset(res, effectiveOwnerId, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original');
} catch {
res.status(404).json({ error: 'Provider not supported' });
}
}
});
export default router;
+4
View File
@@ -16,6 +16,7 @@ import {
importGoogleList, importGoogleList,
searchPlaceImage, searchPlaceImage,
} from '../services/placeService'; } from '../services/placeService';
import { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } from '../services/journeyService';
const gpxUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }); const gpxUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
@@ -49,6 +50,7 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name:
const place = createPlace(tripId, req.body); const place = createPlace(tripId, req.body);
res.status(201).json({ place }); res.status(201).json({ place });
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
try { onPlaceCreated(Number(tripId), place.id); } catch {}
}); });
// Import places from GPX file with full track geometry (must be before /:id) // Import places from GPX file with full track geometry (must be before /:id)
@@ -142,6 +144,7 @@ router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name
res.json({ place }); res.json({ place });
broadcast(tripId, 'place:updated', { place }, req.headers['x-socket-id'] as string); broadcast(tripId, 'place:updated', { place }, req.headers['x-socket-id'] as string);
try { onPlaceUpdated(place.id); } catch {}
}); });
router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => { router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
@@ -151,6 +154,7 @@ router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Respo
const { tripId, id } = req.params; const { tripId, id } = req.params;
try { onPlaceDeleted(Number(id)); } catch {} // sync before actual delete
const deleted = deletePlace(tripId, id); const deleted = deletePlace(tripId, id);
if (!deleted) { if (!deleted) {
return res.status(404).json({ error: 'Place not found' }); return res.status(404).json({ error: 'Place not found' });
+727
View File
@@ -0,0 +1,727 @@
import { db } from '../db/database';
import { broadcastToUser } from '../websocket';
import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../types';
function ts(): number {
return Date.now();
}
function broadcastJourneyEvent(journeyId: number, event: string, data: Record<string, unknown>, excludeUserId?: number) {
const contributors = db.prepare(
'SELECT user_id FROM journey_contributors WHERE journey_id = ?'
).all(journeyId) as { user_id: number }[];
const owner = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(journeyId) as { user_id: number } | undefined;
const userIds = new Set(contributors.map(c => c.user_id));
if (owner) userIds.add(owner.user_id);
for (const uid of userIds) {
if (uid === excludeUserId) continue;
broadcastToUser(uid, { type: event, journeyId, ...data });
}
}
// ── Access control ───────────────────────────────────────────────────────
export function canAccessJourney(journeyId: number, userId: number): Journey | null {
const own = db.prepare('SELECT * FROM journeys WHERE id = ? AND user_id = ?').get(journeyId, userId) as Journey | undefined;
if (own) return own;
const contrib = db.prepare(
'SELECT 1 FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
).get(journeyId, userId);
if (contrib) return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey || null;
return null;
}
export function isOwner(journeyId: number, userId: number): boolean {
return !!db.prepare('SELECT 1 FROM journeys WHERE id = ? AND user_id = ?').get(journeyId, userId);
}
export function canEdit(journeyId: number, userId: number): boolean {
if (isOwner(journeyId, userId)) return true;
const c = db.prepare(
"SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?"
).get(journeyId, userId) as { role: string } | undefined;
return c?.role === 'editor' || c?.role === 'owner';
}
// ── Journey CRUD ─────────────────────────────────────────────────────────
export function listJourneys(userId: number) {
return db.prepare(`
SELECT DISTINCT j.*,
(SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count,
(SELECT COUNT(*) FROM journey_photos jp JOIN journey_entries je2 ON jp.entry_id = je2.id WHERE je2.journey_id = j.id) as photo_count,
(SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as city_count
FROM journeys j
LEFT JOIN journey_contributors jc ON j.id = jc.journey_id AND jc.user_id = ?
WHERE j.user_id = ? OR jc.user_id = ?
ORDER BY j.updated_at DESC
`).all(userId, userId, userId) as (Journey & { entry_count: number; photo_count: number; city_count: number })[];
}
export function createJourney(userId: number, data: {
title: string;
subtitle?: string;
trip_ids?: number[];
}): Journey {
const now = ts();
const res = db.prepare(`
INSERT INTO journeys (user_id, title, subtitle, status, created_at, updated_at)
VALUES (?, ?, ?, 'active', ?, ?)
`).run(userId, data.title, data.subtitle || null, now, now);
const journeyId = Number(res.lastInsertRowid);
// add owner as contributor
db.prepare(
'INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)'
).run(journeyId, userId, 'owner', now);
// link trips and sync skeleton entries
if (data.trip_ids?.length) {
for (const tripId of data.trip_ids) {
addTripToJourney(journeyId, tripId, userId);
}
}
return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey;
}
export function getJourneyFull(journeyId: number, userId: number) {
const journey = canAccessJourney(journeyId, userId);
if (!journey) return null;
const entries = db.prepare(
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
).all(journeyId) as JourneyEntry[];
const photos = db.prepare(
'SELECT * FROM journey_photos WHERE entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY sort_order ASC'
).all(journeyId) as JourneyPhoto[];
// group photos by entry
const photosByEntry: Record<number, JourneyPhoto[]> = {};
for (const p of photos) {
(photosByEntry[p.entry_id] ||= []).push(p);
}
const enrichedEntries = entries.map(e => ({
...e,
tags: e.tags ? JSON.parse(e.tags) : [],
pros_cons: e.pros_cons ? JSON.parse(e.pros_cons) : null,
photos: photosByEntry[e.id] || [],
source_trip_name: e.source_trip_id
? (db.prepare('SELECT title FROM trips WHERE id = ?').get(e.source_trip_id) as { title: string } | undefined)?.title || null
: null,
}));
// linked trips
const trips = db.prepare(`
SELECT jt.trip_id, jt.added_at, t.title, t.start_date, t.end_date, t.cover_image, t.currency,
(SELECT COUNT(*) FROM places WHERE trip_id = t.id) as place_count
FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id
WHERE jt.journey_id = ? ORDER BY t.start_date ASC
`).all(journeyId);
// contributors
const contributors = db.prepare(`
SELECT jc.journey_id, jc.user_id, jc.role, jc.added_at, u.username, u.avatar
FROM journey_contributors jc JOIN users u ON jc.user_id = u.id
WHERE jc.journey_id = ? ORDER BY jc.added_at
`).all(journeyId);
// stats
const entryCount = entries.filter(e => e.type === 'entry').length;
const photoCount = photos.length;
const cities = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
return {
...journey,
entries: enrichedEntries,
trips,
contributors,
stats: { entries: entryCount, photos: photoCount, cities: cities.length },
};
}
export function updateJourney(journeyId: number, userId: number, data: Partial<{
title: string;
subtitle: string;
cover_gradient: string;
cover_image: string;
status: string;
}>): Journey | null {
if (!canEdit(journeyId, userId)) return null;
const allowed = ['title', 'subtitle', 'cover_gradient', 'cover_image', 'status'];
const fields: string[] = [];
const values: unknown[] = [];
for (const [key, val] of Object.entries(data)) {
if (val !== undefined && allowed.includes(key)) {
fields.push(`${key} = ?`);
values.push(val);
}
}
if (fields.length === 0) return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey;
fields.push('updated_at = ?');
values.push(ts());
values.push(journeyId);
db.prepare(`UPDATE journeys SET ${fields.join(', ')} WHERE id = ?`).run(...values);
return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey;
}
export function deleteJourney(journeyId: number, userId: number): boolean {
if (!isOwner(journeyId, userId)) return false;
db.prepare('DELETE FROM journeys WHERE id = ?').run(journeyId);
return true;
}
// ── Trip management ──────────────────────────────────────────────────────
export function addTripToJourney(journeyId: number, tripId: number, userId: number): boolean {
const now = ts();
try {
db.prepare(
'INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at) VALUES (?, ?, ?)'
).run(journeyId, tripId, now);
} catch { return false; }
// sync skeleton entries for all places in this trip
syncTripPlaces(journeyId, tripId, userId);
// import existing trip photos (Immich/Synology) with sharing settings
syncTripPhotos(journeyId, tripId);
broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId }, userId);
return true;
}
export function removeTripFromJourney(journeyId: number, tripId: number, userId: number): boolean {
if (!isOwner(journeyId, userId)) return false;
// remove skeleton entries that haven't been filled in
db.prepare(`
DELETE FROM journey_entries
WHERE journey_id = ? AND source_trip_id = ? AND type = 'skeleton'
`).run(journeyId, tripId);
// detach filled entries from this trip
db.prepare(`
UPDATE journey_entries SET source_trip_id = NULL, source_place_id = NULL
WHERE journey_id = ? AND source_trip_id = ? AND type != 'skeleton'
`).run(journeyId, tripId);
db.prepare('DELETE FROM journey_trips WHERE journey_id = ? AND trip_id = ?').run(journeyId, tripId);
return true;
}
// ── Sync engine ──────────────────────────────────────────────────────────
export function syncTripPlaces(journeyId: number, tripId: number, authorId: number) {
const places = db.prepare(`
SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, da.assignment_end_time, d.day_number
FROM places p
LEFT JOIN day_assignments da ON da.place_id = p.id
LEFT JOIN days d ON da.day_id = d.id
WHERE p.trip_id = ?
ORDER BY d.day_number ASC, da.order_index ASC
`).all(tripId) as any[];
const now = ts();
const existing = db.prepare(
'SELECT source_place_id FROM journey_entries WHERE journey_id = ? AND source_trip_id = ?'
).all(journeyId, tripId) as { source_place_id: number }[];
const existingPlaceIds = new Set(existing.map(e => e.source_place_id));
for (const place of places) {
if (existingPlaceIds.has(place.id)) continue;
const entryDate = place.day_date || new Date().toISOString().split('T')[0];
const entryTime = place.assignment_time || place.place_time || null;
db.prepare(`
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
journeyId, tripId, place.id, authorId,
place.name, entryDate, entryTime,
place.address || place.name, place.lat || null, place.lng || null,
place.day_number || 0, now, now
);
}
}
// import trip_photos into journey when a trip is linked
function syncTripPhotos(journeyId: number, tripId: number) {
const tripPhotos = db.prepare(
'SELECT * FROM trip_photos WHERE trip_id = ?'
).all(tripId) as { id: number; trip_id: number; user_id: number; asset_id: string; provider: string; shared: number }[];
if (!tripPhotos.length) return;
const now = ts();
// find or create a "Photos" entry for this trip's photos
let photoEntry = db.prepare(`
SELECT id FROM journey_entries
WHERE journey_id = ? AND source_trip_id = ? AND title = '[Trip Photos]' AND type = 'entry'
`).get(journeyId, tripId) as { id: number } | undefined;
if (!photoEntry) {
// get trip date for the entry
const trip = db.prepare('SELECT start_date FROM trips WHERE id = ?').get(tripId) as { start_date: string } | undefined;
const entryDate = trip?.start_date || new Date().toISOString().split('T')[0];
const owner = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(journeyId) as { user_id: number };
const res = db.prepare(`
INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, sort_order, created_at, updated_at)
VALUES (?, ?, ?, 'entry', '[Trip Photos]', ?, 999, ?, ?)
`).run(journeyId, tripId, owner.user_id, entryDate, now, now);
photoEntry = { id: Number(res.lastInsertRowid) };
}
// import each trip photo, skip duplicates
for (const tp of tripPhotos) {
const exists = db.prepare(
'SELECT 1 FROM journey_photos WHERE entry_id = ? AND provider = ? AND asset_id = ?'
).get(photoEntry.id, tp.provider, tp.asset_id);
if (exists) continue;
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(photoEntry.id) as { m: number | null };
db.prepare(`
INSERT INTO journey_photos (entry_id, provider, asset_id, owner_id, shared, sort_order, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(photoEntry.id, tp.provider, tp.asset_id, tp.user_id, tp.shared, (maxOrder?.m ?? -1) + 1, now);
}
}
// called when a trip place is created
export function onPlaceCreated(tripId: number, placeId: number) {
const links = db.prepare('SELECT journey_id FROM journey_trips WHERE trip_id = ?').all(tripId) as { journey_id: number }[];
if (!links.length) return;
const place = db.prepare(`
SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, d.day_number
FROM places p
LEFT JOIN day_assignments da ON da.place_id = p.id
LEFT JOIN days d ON da.day_id = d.id
WHERE p.id = ?
`).get(placeId) as any;
if (!place) return;
const now = ts();
for (const link of links) {
const already = db.prepare(
'SELECT 1 FROM journey_entries WHERE journey_id = ? AND source_place_id = ?'
).get(link.journey_id, placeId);
if (already) continue;
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(link.journey_id) as { user_id: number };
const entryDate = place.day_date || new Date().toISOString().split('T')[0];
db.prepare(`
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, 0, ?, ?)
`).run(
link.journey_id, tripId, placeId, journey.user_id,
place.name, entryDate, place.assignment_time || place.place_time || null,
place.address || place.name, place.lat || null, place.lng || null,
now, now
);
}
}
// called when a trip place is updated
export function onPlaceUpdated(placeId: number) {
const entries = db.prepare(
'SELECT * FROM journey_entries WHERE source_place_id = ?'
).all(placeId) as JourneyEntry[];
if (!entries.length) return;
const place = db.prepare(`
SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, d.day_number
FROM places p
LEFT JOIN day_assignments da ON da.place_id = p.id
LEFT JOIN days d ON da.day_id = d.id
WHERE p.id = ?
`).get(placeId) as any;
if (!place) return;
const now = ts();
for (const entry of entries) {
if (entry.type === 'skeleton') {
// update everything on skeletons
db.prepare(`
UPDATE journey_entries SET title = ?, entry_date = ?, entry_time = ?, location_name = ?, location_lat = ?, location_lng = ?, updated_at = ?
WHERE id = ?
`).run(
place.name,
place.day_date || entry.entry_date,
place.assignment_time || place.place_time || entry.entry_time,
place.address || place.name,
place.lat || null, place.lng || null,
now, entry.id
);
} else {
// for filled entries, only update location silently
db.prepare(`
UPDATE journey_entries SET location_name = ?, location_lat = ?, location_lng = ?, updated_at = ?
WHERE id = ?
`).run(place.address || place.name, place.lat || null, place.lng || null, now, entry.id);
}
}
}
// called when a trip place is deleted
export function onPlaceDeleted(placeId: number) {
const entries = db.prepare(
'SELECT * FROM journey_entries WHERE source_place_id = ?'
).all(placeId) as JourneyEntry[];
for (const entry of entries) {
if (entry.type === 'skeleton') {
// no content: just delete
const hasPhotos = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(entry.id);
if (!hasPhotos && !entry.story) {
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entry.id);
continue;
}
}
// entry has content: keep it, detach, add note
const note = '\n\n> _Note: the original trip place was removed from the trip plan_';
const newStory = (entry.story || '') + note;
db.prepare(
'UPDATE journey_entries SET source_place_id = NULL, source_trip_id = NULL, type = ?, story = ?, updated_at = ? WHERE id = ?'
).run(entry.type === 'skeleton' ? 'entry' : entry.type, newStory, ts(), entry.id);
}
}
// ── Entries ──────────────────────────────────────────────────────────────
export function listEntries(journeyId: number, userId: number) {
if (!canAccessJourney(journeyId, userId)) return null;
const entries = db.prepare(
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
).all(journeyId) as JourneyEntry[];
const photos = db.prepare(
'SELECT * FROM journey_photos WHERE entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY sort_order ASC'
).all(journeyId) as JourneyPhoto[];
const photosByEntry: Record<number, JourneyPhoto[]> = {};
for (const p of photos) {
(photosByEntry[p.entry_id] ||= []).push(p);
}
return entries.map(e => ({
...e,
tags: e.tags ? JSON.parse(e.tags) : [],
pros_cons: e.pros_cons ? JSON.parse(e.pros_cons) : null,
photos: photosByEntry[e.id] || [],
source_trip_name: e.source_trip_id
? (db.prepare('SELECT title FROM trips WHERE id = ?').get(e.source_trip_id) as { title: string } | undefined)?.title || null
: null,
}));
}
export function createEntry(journeyId: number, userId: number, data: {
type?: string;
title?: string;
story?: string;
entry_date: string;
entry_time?: string;
location_name?: string;
location_lat?: number;
location_lng?: number;
mood?: string;
weather?: string;
tags?: string[];
pros_cons?: { pros: string[]; cons: string[] };
visibility?: string;
}): JourneyEntry | null {
if (!canEdit(journeyId, userId)) return null;
const now = ts();
const maxOrder = db.prepare(
'SELECT MAX(sort_order) as m FROM journey_entries WHERE journey_id = ? AND entry_date = ?'
).get(journeyId, data.entry_date) as { m: number | null };
const prosConsJson = data.pros_cons && (data.pros_cons.pros.length || data.pros_cons.cons.length)
? JSON.stringify(data.pros_cons) : null;
const res = db.prepare(`
INSERT INTO journey_entries (journey_id, author_id, type, title, story, entry_date, entry_time, location_name, location_lat, location_lng, mood, weather, tags, pros_cons, visibility, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
journeyId, userId,
data.type || 'entry',
data.title || null,
data.story || null,
data.entry_date,
data.entry_time || null,
data.location_name || null,
data.location_lat ?? null,
data.location_lng ?? null,
data.mood || null,
data.weather || null,
data.tags?.length ? JSON.stringify(data.tags) : null,
prosConsJson,
data.visibility || 'private',
(maxOrder?.m ?? -1) + 1,
now, now
);
const created = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(Number(res.lastInsertRowid)) as JourneyEntry;
broadcastJourneyEvent(journeyId, 'journey:entry:created', { entry: created }, userId);
return created;
}
export function updateEntry(entryId: number, userId: number, data: Partial<{
type: string;
title: string;
story: string;
entry_date: string;
entry_time: string;
location_name: string;
location_lat: number;
location_lng: number;
mood: string;
weather: string;
tags: string[];
pros_cons: { pros: string[]; cons: string[] };
visibility: string;
sort_order: number;
}>): JourneyEntry | null {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null;
const fields: string[] = [];
const values: unknown[] = [];
for (const [key, val] of Object.entries(data)) {
if (val === undefined) continue;
if (key === 'tags') {
fields.push('tags = ?');
values.push(Array.isArray(val) ? JSON.stringify(val) : val);
} else if (key === 'pros_cons') {
fields.push('pros_cons = ?');
values.push(val && typeof val === 'object' ? JSON.stringify(val) : val);
} else {
fields.push(`${key} = ?`);
values.push(val);
}
}
// if adding story to a skeleton, promote to entry
if (entry.type === 'skeleton' && data.story && data.story.trim()) {
fields.push('type = ?');
values.push('entry');
}
if (fields.length === 0) return entry;
fields.push('updated_at = ?');
values.push(ts());
values.push(entryId);
db.prepare(`UPDATE journey_entries SET ${fields.join(', ')} WHERE id = ?`).run(...values);
// touch the journey
db.prepare('UPDATE journeys SET updated_at = ? WHERE id = ?').run(ts(), entry.journey_id);
const updated = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry;
broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entry: updated }, userId);
return updated;
}
export function deleteEntry(entryId: number, userId: number): boolean {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return false;
if (!canEdit(entry.journey_id, userId)) return false;
// move photos to hidden Gallery entry so they stay in the gallery
const hasPhotos = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(entryId);
if (hasPhotos) {
let gallery = db.prepare(
"SELECT id FROM journey_entries WHERE journey_id = ? AND title = 'Gallery' AND id != ?"
).get(entry.journey_id, entryId) as { id: number } | undefined;
if (!gallery) {
const now = ts();
const res = db.prepare(`
INSERT INTO journey_entries (journey_id, author_id, type, title, entry_date, sort_order, created_at, updated_at)
VALUES (?, ?, 'entry', 'Gallery', ?, 999, ?, ?)
`).run(entry.journey_id, entry.author_id, entry.entry_date, now, now);
gallery = { id: Number(res.lastInsertRowid) };
}
db.prepare('UPDATE journey_photos SET entry_id = ? WHERE entry_id = ?').run(gallery.id, entryId);
}
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId);
broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, userId);
return true;
}
// ── Photos ───────────────────────────────────────────────────────────────
export function addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath?: string, caption?: string): JourneyPhoto | null {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null;
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
const now = ts();
const res = db.prepare(`
INSERT INTO journey_photos (entry_id, provider, file_path, thumbnail_path, caption, sort_order, created_at)
VALUES (?, 'local', ?, ?, ?, ?, ?)
`).run(entryId, filePath, thumbnailPath || null, caption || null, (maxOrder?.m ?? -1) + 1, now);
return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(Number(res.lastInsertRowid)) as JourneyPhoto;
}
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string): JourneyPhoto | null {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null;
// skip if already added
const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND provider = ? AND asset_id = ?').get(entryId, provider, assetId);
if (exists) return null;
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
const now = ts();
const res = db.prepare(`
INSERT INTO journey_photos (entry_id, provider, asset_id, owner_id, caption, sort_order, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(entryId, provider, assetId, userId, caption || null, (maxOrder?.m ?? -1) + 1, now);
return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(Number(res.lastInsertRowid)) as JourneyPhoto;
}
export function linkPhotoToEntry(entryId: number, photoId: number, userId: number): JourneyPhoto | null {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null;
const source = db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photoId) as JourneyPhoto | undefined;
if (!source) return null;
if (source.entry_id === entryId) return source;
const oldEntryId = source.entry_id;
// move photo to the target entry
db.prepare('UPDATE journey_photos SET entry_id = ? WHERE id = ?').run(entryId, photoId);
// clean up: if old entry was a "Gallery" entry and is now empty, delete it
const oldEntry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(oldEntryId) as JourneyEntry | undefined;
if (oldEntry && oldEntry.title === 'Gallery') {
const remaining = db.prepare('SELECT COUNT(*) as c FROM journey_photos WHERE entry_id = ?').get(oldEntryId) as { c: number };
if (remaining.c === 0) {
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(oldEntryId);
}
}
return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photoId) as JourneyPhoto;
}
export function setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) {
db.prepare('UPDATE journey_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?').run(provider, assetId, ownerId, photoId);
}
export function updatePhoto(photoId: number, userId: number, data: { caption?: string; sort_order?: number }): JourneyPhoto | null {
const photo = db.prepare(`
SELECT jp.*, je.journey_id FROM journey_photos jp
JOIN journey_entries je ON jp.entry_id = je.id
WHERE jp.id = ?
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
if (!photo) return null;
if (!canEdit(photo.journey_id, userId)) return null;
const fields: string[] = [];
const values: unknown[] = [];
if (data.caption !== undefined) { fields.push('caption = ?'); values.push(data.caption); }
if (data.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(data.sort_order); }
if (!fields.length) return photo;
values.push(photoId);
db.prepare(`UPDATE journey_photos SET ${fields.join(', ')} WHERE id = ?`).run(...values);
return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photoId) as JourneyPhoto;
}
export function deletePhoto(photoId: number, userId: number): (JourneyPhoto & { journey_id: number }) | null {
const photo = db.prepare(`
SELECT jp.*, je.journey_id FROM journey_photos jp
JOIN journey_entries je ON jp.entry_id = je.id
WHERE jp.id = ?
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
if (!photo) return null;
if (!canEdit(photo.journey_id, userId)) return null;
db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId);
return photo;
}
// ── Contributors ─────────────────────────────────────────────────────────
export function addContributor(journeyId: number, userId: number, targetUserId: number, role: 'editor' | 'viewer'): boolean {
if (!isOwner(journeyId, userId)) return false;
if (targetUserId === userId) return false;
try {
db.prepare(
'INSERT OR REPLACE INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)'
).run(journeyId, targetUserId, role, ts());
broadcastJourneyEvent(journeyId, 'journey:contributor:changed', { targetUserId, role });
return true;
} catch { return false; }
}
export function updateContributorRole(journeyId: number, userId: number, targetUserId: number, role: 'editor' | 'viewer'): boolean {
if (!isOwner(journeyId, userId)) return false;
db.prepare(
'UPDATE journey_contributors SET role = ? WHERE journey_id = ? AND user_id = ?'
).run(role, journeyId, targetUserId);
broadcastJourneyEvent(journeyId, 'journey:contributor:changed', { targetUserId, role });
return true;
}
export function removeContributor(journeyId: number, userId: number, targetUserId: number): boolean {
if (!isOwner(journeyId, userId)) return false;
db.prepare(
"DELETE FROM journey_contributors WHERE journey_id = ? AND user_id = ? AND role != 'owner'"
).run(journeyId, targetUserId);
return true;
}
// ── Suggestions ──────────────────────────────────────────────────────────
export function getSuggestions(userId: number) {
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
return db.prepare(`
SELECT t.id, t.title, t.start_date, t.end_date, t.cover_image,
(SELECT COUNT(*) FROM places WHERE trip_id = t.id) as place_count
FROM trips t
LEFT JOIN trip_members tm ON t.id = tm.trip_id AND tm.user_id = ?
WHERE (t.user_id = ? OR tm.user_id = ?)
AND t.end_date IS NOT NULL
AND t.end_date >= ?
AND t.end_date <= date('now')
AND t.id NOT IN (SELECT trip_id FROM journey_trips)
ORDER BY t.end_date DESC
`).all(userId, userId, userId, thirtyDaysAgo);
}
// ── User trips (for trip picker) ─────────────────────────────────────────
export function listUserTrips(userId: number) {
return db.prepare(`
SELECT t.id, t.title, t.start_date, t.end_date, t.cover_image,
(SELECT COUNT(*) FROM places WHERE trip_id = t.id) as place_count
FROM trips t
LEFT JOIN trip_members tm ON t.id = tm.trip_id AND tm.user_id = ?
WHERE t.user_id = ? OR tm.user_id = ?
ORDER BY t.start_date DESC
`).all(userId, userId, userId);
}
+143
View File
@@ -0,0 +1,143 @@
import { db } from '../db/database';
import crypto from 'crypto';
interface JourneySharePermissions {
share_timeline?: boolean;
share_gallery?: boolean;
share_map?: boolean;
}
interface JourneyShareTokenInfo {
token: string;
created_at: string;
share_timeline: boolean;
share_gallery: boolean;
share_map: boolean;
}
export function createOrUpdateJourneyShareLink(
journeyId: number,
createdBy: number,
permissions: JourneySharePermissions
): { token: string; created: boolean } {
const {
share_timeline = true,
share_gallery = true,
share_map = true,
} = permissions;
const existing = db.prepare('SELECT token FROM journey_share_tokens WHERE journey_id = ?').get(journeyId) as { token: string } | undefined;
if (existing) {
db.prepare('UPDATE journey_share_tokens SET share_timeline = ?, share_gallery = ?, share_map = ? WHERE journey_id = ?')
.run(share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, journeyId);
return { token: existing.token, created: false };
}
const token = crypto.randomBytes(24).toString('base64url');
db.prepare('INSERT INTO journey_share_tokens (journey_id, token, created_by, share_timeline, share_gallery, share_map) VALUES (?, ?, ?, ?, ?, ?)')
.run(journeyId, token, createdBy, share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0);
return { token, created: true };
}
export function getJourneyShareLink(journeyId: number): JourneyShareTokenInfo | null {
const row = db.prepare('SELECT * FROM journey_share_tokens WHERE journey_id = ?').get(journeyId) as any;
if (!row) return null;
return {
token: row.token,
created_at: row.created_at,
share_timeline: !!row.share_timeline,
share_gallery: !!row.share_gallery,
share_map: !!row.share_map,
};
}
export function deleteJourneyShareLink(journeyId: number): void {
db.prepare('DELETE FROM journey_share_tokens WHERE journey_id = ?').run(journeyId);
}
export function validateShareTokenForPhoto(token: string, photoId: number): { journeyId: number; ownerId: number } | null {
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null;
const photo = db.prepare(`
SELECT jp.*, je.journey_id FROM journey_photos jp
JOIN journey_entries je ON jp.entry_id = je.id
WHERE jp.id = ? AND je.journey_id = ?
`).get(photoId, row.journey_id) as any;
if (!photo) return null;
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
return journey ? { journeyId: row.journey_id, ownerId: photo.owner_id || journey.user_id } : null;
}
export function validateShareTokenForAsset(token: string, assetId: string): { ownerId: number } | null {
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null;
// Check if this asset belongs to any photo in the shared journey
const photo = db.prepare(`
SELECT jp.owner_id FROM journey_photos jp
JOIN journey_entries je ON jp.entry_id = je.id
WHERE jp.asset_id = ? AND je.journey_id = ?
`).get(assetId, row.journey_id) as any;
if (!photo) {
// Fallback: get journey owner
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
return journey ? { ownerId: journey.user_id } : null;
}
return { ownerId: photo.owner_id };
}
export function getPublicJourney(token: string) {
const row = db.prepare('SELECT * FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null;
const journey = db.prepare('SELECT * FROM journeys WHERE id = ?').get(row.journey_id) as any;
if (!journey) return null;
// Entries with photos
const entries = db.prepare(`
SELECT je.* FROM journey_entries je
WHERE je.journey_id = ? AND je.type != 'skeleton'
ORDER BY je.entry_date, je.sort_order
`).all(row.journey_id) as any[];
const photos = db.prepare(`
SELECT jp.* FROM journey_photos jp
JOIN journey_entries je ON jp.entry_id = je.id
WHERE je.journey_id = ?
ORDER BY jp.sort_order
`).all(row.journey_id) as any[];
const photosByEntry: Record<number, any[]> = {};
for (const p of photos) {
(photosByEntry[p.entry_id] ||= []).push(p);
}
const enrichedEntries = entries.map(e => ({
...e,
tags: e.tags ? JSON.parse(e.tags) : [],
pros_cons: e.pros_cons ? JSON.parse(e.pros_cons) : null,
photos: photosByEntry[e.id] || [],
}));
// Stats
const stats = {
entries: entries.length,
photos: photos.length,
cities: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
};
return {
journey: {
title: journey.title,
subtitle: journey.subtitle,
cover_image: journey.cover_image,
status: journey.status,
},
entries: enrichedEntries,
stats,
permissions: {
share_timeline: !!row.share_timeline,
share_gallery: !!row.share_gallery,
share_map: !!row.share_map,
},
};
}
@@ -123,6 +123,31 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
if (requestingUserId === ownerUserId) { if (requestingUserId === ownerUserId) {
return true; return true;
} }
// Journey photos use tripId=0 — check journey_photos + journey_contributors
if (tripId === '0') {
const journeyPhoto = db.prepare(`
SELECT jp.entry_id, je.journey_id
FROM journey_photos jp
JOIN journey_entries je ON je.id = jp.entry_id
WHERE jp.asset_id = ?
AND jp.provider = ?
AND jp.owner_id = ?
LIMIT 1
`).get(assetId, provider, ownerUserId) as { entry_id: number; journey_id: number } | undefined;
if (!journeyPhoto) return false;
// Check if requesting user is the journey owner or a contributor
const access = db.prepare(`
SELECT 1 FROM journeys WHERE id = ? AND user_id = ?
UNION ALL
SELECT 1 FROM journey_contributors WHERE journey_id = ? AND user_id = ?
LIMIT 1
`).get(journeyPhoto.journey_id, requestingUserId, journeyPhoto.journey_id, requestingUserId);
return !!access;
}
// Regular trip photos
const sharedAsset = db.prepare(` const sharedAsset = db.prepare(`
SELECT 1 SELECT 1
FROM trip_photos FROM trip_photos
@@ -357,3 +357,63 @@ export async function syncAlbumAssets(
return { error: 'Could not reach Immich', status: 502 }; return { error: 'Could not reach Immich', status: 502 };
} }
} }
// ── Upload to Immich ──────────────────────────────────────────────────────
export async function uploadToImmich(userId: number, filePath: string, fileName: string): Promise<string | null> {
const creds = getImmichCredentials(userId);
if (!creds) return null;
const fs = await import('node:fs');
const path = await import('node:path');
const fullPath = path.join(__dirname, '../../../uploads', filePath);
if (!fs.existsSync(fullPath)) return null;
try {
const fileBuffer = fs.readFileSync(fullPath);
const boundary = '----ImmichUpload' + Date.now();
const ext = path.extname(fileName).toLowerCase();
const mimeTypes: Record<string, string> = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
'.gif': 'image/gif', '.webp': 'image/webp', '.heic': 'image/heic',
};
const contentType = mimeTypes[ext] || 'application/octet-stream';
const now = new Date().toISOString();
const parts: Buffer[] = [];
const addField = (name: string, value: string) => {
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`));
};
addField('deviceAssetId', `trek-${Date.now()}`);
addField('deviceId', 'TREK');
addField('fileCreatedAt', now);
addField('fileModifiedAt', now);
parts.push(Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="assetData"; filename="${fileName}"\r\nContent-Type: ${contentType}\r\n\r\n`
));
parts.push(fileBuffer);
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
const body = Buffer.concat(parts);
const res = await safeFetch(`${creds.immich_url}/api/assets`, {
method: 'POST',
headers: {
'x-api-key': creds.immich_api_key,
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': String(body.length),
},
body,
});
if (res.ok) {
const data = await res.json() as { id?: string };
return data.id || null;
}
return null;
} catch {
return null;
}
}
+12
View File
@@ -259,6 +259,18 @@ export function deleteTrip(tripId: string | number, userId: number, userRole: st
ownerEmail = (db.prepare('SELECT email FROM users WHERE id = ?').get(trip.user_id) as { email: string } | undefined)?.email; ownerEmail = (db.prepare('SELECT email FROM users WHERE id = ?').get(trip.user_id) as { email: string } | undefined)?.email;
} }
// Clean up journey entries synced from this trip before deleting
// Delete skeleton entries (unfilled synced places)
db.prepare(`
DELETE FROM journey_entries
WHERE source_trip_id = ? AND type = 'skeleton'
`).run(tripId);
// Detach filled entries (keep user's written content, just remove trip link)
db.prepare(`
UPDATE journey_entries SET source_trip_id = NULL, source_place_id = NULL
WHERE source_trip_id = ?
`).run(tripId);
db.prepare('DELETE FROM trips WHERE id = ?').run(tripId); db.prepare('DELETE FROM trips WHERE id = ?').run(tripId);
return { tripId: Number(tripId), title: trip.title, ownerId: trip.user_id, isAdminDelete, ownerEmail }; return { tripId: Number(tripId), title: trip.title, ownerId: trip.user_id, isAdminDelete, ownerEmail };
+66
View File
@@ -301,3 +301,69 @@ export interface Participant {
username: string; username: string;
avatar?: string | null; avatar?: string | null;
} }
// ── Journey addon ─────────────────────────────────────────────────────────
export interface Journey {
id: number;
user_id: number;
title: string;
subtitle?: string | null;
cover_gradient?: string | null;
cover_image?: string | null;
status: 'draft' | 'active' | 'completed';
created_at: number;
updated_at: number;
}
export interface JourneyEntry {
id: number;
journey_id: number;
source_trip_id?: number | null;
source_place_id?: number | null;
author_id: number;
type: 'entry' | 'checkin' | 'skeleton';
title?: string | null;
story?: string | null;
entry_date: string;
entry_time?: string | null;
location_name?: string | null;
location_lat?: number | null;
location_lng?: number | null;
mood?: string | null;
weather?: string | null;
tags?: string | null;
visibility: 'private' | 'shared' | 'public';
sort_order: number;
created_at: number;
updated_at: number;
}
export interface JourneyPhoto {
id: number;
entry_id: number;
provider: 'local' | 'immich' | 'synologyphotos';
asset_id?: string | null;
owner_id?: number | null;
file_path?: string | null;
thumbnail_path?: string | null;
caption?: string | null;
sort_order: number;
width?: number | null;
height?: number | null;
shared: number;
created_at: number;
}
export interface JourneyTrip {
journey_id: number;
trip_id: number;
added_at: number;
}
export interface JourneyContributor {
journey_id: number;
user_id: number;
role: 'owner' | 'editor' | 'viewer';
added_at: number;
}