mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
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:
+1
-1
@@ -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 -->
|
||||||
|
|||||||
Generated
+13
-87
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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={
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
function md(str: string | null | undefined): string {
|
||||||
|
if (!str) return ''
|
||||||
|
return marked.parse(str, { async: false, breaks: true }) as string
|
||||||
|
}
|
||||||
|
|
||||||
|
function abs(url: string | null | undefined): string {
|
||||||
|
if (!url) return ''
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url
|
||||||
|
return window.location.origin + (url.startsWith('/') ? '' : '/') + url
|
||||||
|
}
|
||||||
|
|
||||||
|
function pSrc(p: JourneyPhoto): string {
|
||||||
|
if (p.provider === 'local') return abs(`/uploads/${p.file_path}`)
|
||||||
|
return abs(`/api/integrations/memories/${p.provider}/assets/0/${p.asset_id}/${p.owner_id}/original`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(d: string): string {
|
||||||
|
const date = new Date(d + 'T00:00:00')
|
||||||
|
return date.toLocaleDateString('en', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtShort(d: string): string {
|
||||||
|
return new Date(d + 'T00:00:00').toLocaleDateString('en', { month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByDate(entries: JourneyEntry[]): Map<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>
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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; }
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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' });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user