mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 14:51:45 +00:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2a39a3071 | |||
| 21511c2f68 | |||
| 0e5c819f7c | |||
| 0f44d7d264 | |||
| e078a9d9e1 | |||
| fef12b0e8b | |||
| df075630fb | |||
| bffb55d8c0 | |||
| 5c24213b0e | |||
| 12a457801a | |||
| ae4d317dc3 | |||
| f7c6854059 | |||
| bdb6b01765 | |||
| 129dfabaa3 | |||
| 8a6d1b2aaf | |||
| 465b78411a | |||
| 272b32b410 | |||
| 7945e752d6 | |||
| 6eb3ab38fb | |||
| c7a9210215 | |||
| d5d63aa979 | |||
| 84574020f2 | |||
| 1b7ea2c87d | |||
| 47b7678975 | |||
| da70388f4b | |||
| 6c1a795460 | |||
| 75d23eb6aa | |||
| 0c4de72356 | |||
| 5e8602c50a | |||
| 61b8070626 | |||
| 5caaeff67c | |||
| 92a1f9c448 | |||
| 58a8e97f94 | |||
| 815b725f87 | |||
| d80bbd5bed | |||
| 293506217e | |||
| 9739542a3a | |||
| 9f3a88223d | |||
| 409a63633c | |||
| 125436fa87 | |||
| 975846c236 | |||
| 7befb7d555 | |||
| 099255761c | |||
| c8fc21b8bd | |||
| 9186b8c850 | |||
| e38c5fed44 | |||
| 3b069bc543 | |||
| 618b1b8697 | |||
| e45a0efce3 | |||
| 597a5f7a1d | |||
| 42c216b00b | |||
| f3751ab9aa |
@@ -1,11 +1,6 @@
|
||||
name: Build & Push Docker Image (Prerelease)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
|
||||
Generated
+40
-24
@@ -22,6 +22,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.22.2",
|
||||
"react-window": "^2.2.7",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
@@ -171,7 +172,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1807,7 +1807,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
@@ -1856,7 +1855,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
@@ -3825,7 +3823,8 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@@ -3966,7 +3965,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -3978,7 +3976,6 @@
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
@@ -4221,7 +4218,6 @@
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -4249,6 +4245,7 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -4649,7 +4646,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
@@ -5397,7 +5393,8 @@
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
@@ -6337,6 +6334,21 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-sanitize": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz",
|
||||
"integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@ungap/structured-clone": "^1.0.0",
|
||||
"unist-util-position": "^5.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-jsx-runtime": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
|
||||
@@ -7133,7 +7145,6 @@
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -7261,8 +7272,7 @@
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/leaflet.markercluster": {
|
||||
"version": "1.5.3",
|
||||
@@ -7397,6 +7407,7 @@
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -8437,7 +8448,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@inquirer/confirm": "^5.0.0",
|
||||
"@mswjs/interceptors": "^0.41.2",
|
||||
@@ -8823,7 +8833,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -8985,6 +8994,7 @@
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -9085,7 +9095,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -9098,7 +9107,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -9138,14 +9146,14 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
|
||||
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^2.1.0"
|
||||
},
|
||||
@@ -9388,6 +9396,20 @@
|
||||
"regjsparser": "bin/parser"
|
||||
}
|
||||
},
|
||||
"node_modules/rehype-sanitize": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz",
|
||||
"integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"hast-util-sanitize": "^5.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-breaks": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz",
|
||||
@@ -9540,7 +9562,6 @@
|
||||
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -10658,7 +10679,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -10908,7 +10928,6 @@
|
||||
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -11227,7 +11246,6 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -11356,7 +11374,6 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -11854,7 +11871,6 @@
|
||||
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.22.2",
|
||||
"react-window": "^2.2.7",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
|
||||
+18
-3
@@ -2,6 +2,7 @@ import React, { useEffect, ReactNode } from 'react'
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import { useSettingsStore } from './store/settingsStore'
|
||||
import { useAddonStore } from './store/addonStore'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import TripPlannerPage from './pages/TripPlannerPage'
|
||||
@@ -24,17 +25,22 @@ import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
|
||||
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
|
||||
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
|
||||
import OfflineBanner from './components/Layout/OfflineBanner'
|
||||
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
||||
// Notice action registrations (side-effect imports):
|
||||
import './pages/Trips/noticeActions.js'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode
|
||||
adminRequired?: boolean
|
||||
addonId?: string
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps) {
|
||||
function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedRouteProps) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const isLoading = useAuthStore((s) => s.isLoading)
|
||||
const appRequireMfa = useAuthStore((s) => s.appRequireMfa)
|
||||
const addonStore = useAddonStore()
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
|
||||
@@ -67,6 +73,10 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps
|
||||
return <Navigate to="/dashboard" replace />
|
||||
}
|
||||
|
||||
if (addonId && addonStore.loaded && !addonStore.isEnabled(addonId)) {
|
||||
return <Navigate to="/dashboard" replace />
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -92,6 +102,7 @@ function RootRedirect() {
|
||||
export default function App() {
|
||||
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
const { loadAddons } = useAddonStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
|
||||
@@ -145,6 +156,7 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadSettings()
|
||||
loadAddons()
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
@@ -182,8 +194,11 @@ export default function App() {
|
||||
applyDark(mode === true || mode === 'dark')
|
||||
}, [settings.dark_mode, isSharedPage])
|
||||
|
||||
const isAuthPage = location.pathname.startsWith('/login') || location.pathname.startsWith('/register')
|
||||
|
||||
return (
|
||||
<TranslationProvider>
|
||||
{!isAuthPage && <SystemNoticeHost />}
|
||||
<ToastContainer />
|
||||
<OfflineBanner />
|
||||
<Routes>
|
||||
@@ -253,7 +268,7 @@ export default function App() {
|
||||
<Route
|
||||
path="/journey"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute addonId="journey">
|
||||
<JourneyPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
@@ -261,7 +276,7 @@ export default function App() {
|
||||
<Route
|
||||
path="/journey/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute addonId="journey">
|
||||
<JourneyDetailPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
|
||||
@@ -272,6 +272,8 @@ export const adminApi = {
|
||||
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
||||
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
|
||||
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
||||
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
|
||||
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
|
||||
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
|
||||
getPackingTemplate: (id: number) => apiClient.get(`/admin/packing-templates/${id}`).then(r => r.data),
|
||||
createPackingTemplate: (data: { name: string }) => apiClient.post('/admin/packing-templates', data).then(r => r.data),
|
||||
@@ -299,6 +301,8 @@ export const adminApi = {
|
||||
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
|
||||
getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data),
|
||||
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
|
||||
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
|
||||
updateDefaultUserSettings: (settings: Record<string, unknown>) => apiClient.put('/admin/default-user-settings', settings).then(r => r.data),
|
||||
}
|
||||
|
||||
export const addonsApi = {
|
||||
|
||||
@@ -4,12 +4,33 @@ import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen } from 'lucide-react'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage } from 'lucide-react'
|
||||
|
||||
const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
|
||||
}
|
||||
|
||||
function ImmichIcon({ size = 14 }: { size?: number }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} style={{ flexShrink: 0 }}>
|
||||
<path d="M11.986.27c-2.409 0-5.207 1.09-5.207 3.894v.152c1.343.597 2.935 1.663 4.412 2.971 1.571 1.391 2.838 2.882 3.653 4.287 1.4-2.503 2.336-5.478 2.347-7.373V4.164c0-2.803-2.796-3.894-5.205-3.894m7.512 4.49c-.378-.008-.775.05-1.192.186l-.144.047c-.153 1.461-.676 3.304-1.463 5.113-.837 1.924-1.863 3.59-2.947 4.799 2.813.558 5.93.527 7.736-.047l.035-.01c2.667-.866 2.84-3.863 2.096-6.154-.628-1.933-2.081-3.89-4.121-3.934m-14.996.04c-2.04.043-3.493 1.997-4.121 3.93-.744 2.291-.571 5.288 2.096 6.155l.144.046c.982-1.092 2.488-2.276 4.188-3.277 1.809-1.065 3.619-1.808 5.207-2.148-1.949-2.105-4.489-3.914-6.287-4.51l-.036-.012c-.416-.135-.813-.193-1.191-.185m4.672 6.758c-2.604 1.202-5.109 3.06-6.233 4.586l-.021.029c-1.648 2.268-.027 4.795 1.922 6.211 1.949 1.416 4.852 2.177 6.5-.092.023-.031.054-.07.09-.121-.736-1.272-1.396-3.072-1.822-4.998-.454-2.05-.603-4-.436-5.615m1.072 3.338c.339 2.848 1.332 5.804 2.436 7.344l.021.029c1.648 2.268 4.551 1.508 6.5.092 1.949-1.416 3.57-3.943 1.922-6.211-.023-.031-.052-.073-.088-.123-1.437.307-3.352.38-5.316.19-2.089-.202-3.99-.663-5.475-1.321" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SynologyIcon({ size = 14 }: { size?: number }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} style={{ flexShrink: 0 }}>
|
||||
<path d="M17.895 11.927a3.196 3.196 0 0 1 .394-1.53l-.008.017a2.677 2.677 0 0 1 1.075-1.108l.014-.007a3.181 3.181 0 0 1 1.523-.382h.05-.003q1.346 0 2.2.871.854.871.86 2.203c0 .895-.29 1.635-.867 2.226s-1.306.886-2.183.886c-.566 0-1.1-.137-1.571-.379l.019.009a2.535 2.535 0 0 1-1.115-1.067l-.007-.013q-.38-.708-.381-1.726zm1.593.083c0 .591.138 1.043.42 1.349a1.365 1.365 0 0 0 2.066.002l.001-.002c.275-.307.413-.764.413-1.357s-.138-1.033-.413-1.342a1.371 1.371 0 0 0-2.066-.001l-.001.002c-.281.306-.42.758-.42 1.345zm-1.602 2.941H16.33v-3.015c0-.635-.032-1.044-.101-1.234a.876.876 0 0 0-.328-.435l-.003-.002a.938.938 0 0 0-.521-.156h-.027.001-.012c-.27 0-.521.084-.727.228l.004-.003a1.115 1.115 0 0 0-.444.576l-.002.008c-.083.248-.121.696-.121 1.359v2.673H12.5V9.027h1.439v.867c.518-.656 1.167-.98 1.952-.98h.021c.335 0 .655.067.946.189l-.016-.006c.261.105.48.268.648.475l.002.003c.141.185.247.404.304.643l.002.012c.057.278.089.597.089.924l-.002.135v-.007zM6.413 9.028h1.654l1.412 4.204 1.376-4.204h1.611l-2.067 5.693-.38 1.038a4.158 4.158 0 0 1-.4.807l.01-.017a1.637 1.637 0 0 1-.422.443l-.005.003c-.17.113-.367.203-.578.26l-.014.003c-.232.064-.499.1-.774.1h-.025.001a4.13 4.13 0 0 1-.911-.105l.028.005-.129-1.229c.198.046.426.074.659.077h.002c.36 0 .628-.106.8-.318a2.27 2.27 0 0 0 .395-.807l.004-.016zM0 12.29l1.592-.149q.147.802.586 1.181.439.379 1.192.375c.528 0 .927-.113 1.197-.335.27-.222.4-.486.4-.782v-.024a.751.751 0 0 0-.167-.474l.001.001c-.113-.132-.309-.252-.59-.347-.193-.074-.631-.191-1.312-.365-.882-.216-1.496-.486-1.85-.804A2.147 2.147 0 0 1 .3 8.936v-.019V8.908c0-.431.132-.831.358-1.163l-.005.007a2.226 2.226 0 0 1 1.003-.826l.015-.005c.442-.184.973-.281 1.602-.281q1.529 0 2.304.676c.516.457.785 1.057.811 1.809l-1.649.055c-.073-.413-.219-.714-.452-.899-.233-.185-.579-.276-1.034-.276-.476 0-.85.098-1.118.298a.59.59 0 0 0-.261.49v.011-.001.002c0 .201.095.379.242.493l.001.001c.205.179.709.36 1.507.546.798.186 1.388.387 1.769.59.374.196.678.48.893.825l.006.01c.214.345.326.786.326 1.305 0 .489-.146.944-.396 1.325l.006-.009c-.264.408-.64.724-1.084.908l-.016.006c-.475.194-1.065.298-1.772.298-1.029 0-1.819-.241-2.373-.722-.554-.481-.879-1.177-.986-2.091z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const PROVIDER_ICONS: Record<string, React.FC<{ size?: number }>> = {
|
||||
immich: ImmichIcon,
|
||||
synologyphotos: SynologyIcon,
|
||||
}
|
||||
|
||||
interface Addon {
|
||||
id: string
|
||||
name: string
|
||||
@@ -38,7 +59,16 @@ function AddonIcon({ name, size = 20 }: AddonIconProps) {
|
||||
return <Icon size={size} />
|
||||
}
|
||||
|
||||
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void }) {
|
||||
interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }
|
||||
|
||||
const COLLAB_SUB_FEATURES = [
|
||||
{ key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
|
||||
{ key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
|
||||
{ key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
|
||||
{ key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' },
|
||||
] as const
|
||||
|
||||
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, collabFeatures, onToggleCollabFeature }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void; collabFeatures?: CollabFeatures; onToggleCollabFeature?: (key: string) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const dm = useSettingsStore(s => s.settings.dark_mode)
|
||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
@@ -156,6 +186,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
|
||||
<div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||
<Luggage size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('admin.bagTracking.title')}</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.bagTracking.subtitle')}</div>
|
||||
@@ -173,6 +204,36 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
|
||||
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||
<div className="space-y-2">
|
||||
{COLLAB_SUB_FEATURES.map(feat => {
|
||||
const enabled = collabFeatures[feat.key]
|
||||
const Icon = feat.icon
|
||||
return (
|
||||
<div key={feat.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||
<Icon size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t(feat.titleKey)}</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t(feat.subtitleKey)}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="hidden sm:inline text-xs font-medium" style={{ color: enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
{enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button onClick={() => onToggleCollabFeature(feat.key)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -194,8 +255,11 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
{addon.id === 'journey' && providerOptions.length > 0 && (
|
||||
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||
<div className="space-y-2">
|
||||
{providerOptions.map(provider => (
|
||||
{providerOptions.map(provider => {
|
||||
const ProviderIcon = PROVIDER_ICONS[provider.key]
|
||||
return (
|
||||
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||
{ProviderIcon && <span style={{ color: 'var(--text-faint)' }}><ProviderIcon size={14} /></span>}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{provider.label}</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div>
|
||||
@@ -214,7 +278,8 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Settings2 } from 'lucide-react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import Section from '../Settings/Section'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { MapView } from '../Map/MapView'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
const MAP_PRESETS = [
|
||||
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
|
||||
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
||||
]
|
||||
|
||||
type Defaults = {
|
||||
temperature_unit?: string
|
||||
dark_mode?: string | boolean
|
||||
time_format?: string
|
||||
route_calculation?: boolean
|
||||
blur_booking_codes?: boolean
|
||||
map_tile_url?: string
|
||||
}
|
||||
|
||||
function OptionRow({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: React.ReactNode
|
||||
hint?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
{label}
|
||||
</label>
|
||||
{hint && <p className="text-xs mb-2" style={{ color: 'var(--text-faint)' }}>{hint}</p>}
|
||||
<div className="flex gap-3 flex-wrap">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OptionButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: active ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [defaults, setDefaults] = useState<Defaults>({})
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [mapTileUrl, setMapTileUrl] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.getDefaultUserSettings().then((data: Defaults) => {
|
||||
setDefaults(data)
|
||||
setMapTileUrl(data.map_tile_url || '')
|
||||
setLoaded(true)
|
||||
}).catch(() => setLoaded(true))
|
||||
}, [])
|
||||
|
||||
const save = async (patch: Partial<Defaults>) => {
|
||||
try {
|
||||
const updated = await adminApi.updateDefaultUserSettings(patch as Record<string, unknown>)
|
||||
setDefaults(updated)
|
||||
toast.success(t('admin.defaultSettings.saved'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const reset = async (key: keyof Defaults) => {
|
||||
try {
|
||||
const updated = await adminApi.updateDefaultUserSettings({ [key]: null })
|
||||
setDefaults(updated)
|
||||
if (key === 'map_tile_url') setMapTileUrl('')
|
||||
toast.success(t('admin.defaultSettings.reset'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const isSet = (key: keyof Defaults) => defaults[key] !== undefined
|
||||
|
||||
const ResetButton = ({ field }: { field: keyof Defaults }) =>
|
||||
isSet(field) ? (
|
||||
<button
|
||||
onClick={() => reset(field)}
|
||||
className="text-xs ml-2"
|
||||
style={{ color: 'var(--text-faint)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{t('admin.defaultSettings.resetToBuiltIn')}
|
||||
</button>
|
||||
) : null
|
||||
|
||||
const mapPreviewPlaces = useMemo((): Place[] => [{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
name: 'Preview center',
|
||||
description: null,
|
||||
notes: null,
|
||||
lat: 48.8566,
|
||||
lng: 2.3522,
|
||||
address: null,
|
||||
category_id: null,
|
||||
icon: null,
|
||||
price: null,
|
||||
currency: null,
|
||||
image_url: null,
|
||||
google_place_id: null,
|
||||
osm_id: null,
|
||||
route_geometry: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
duration_minutes: null,
|
||||
transport_mode: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
created_at: Date(),
|
||||
}], [])
|
||||
|
||||
if (!loaded) {
|
||||
return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic', padding: 16 }}>Loading…</p>
|
||||
}
|
||||
|
||||
const darkMode = defaults.dark_mode
|
||||
|
||||
return (
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
<p className="text-sm" style={{ color: 'var(--text-faint)', marginTop: -8 }}>
|
||||
{t('admin.defaultSettings.description')}
|
||||
</p>
|
||||
|
||||
{/* Color Mode */}
|
||||
<OptionRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
|
||||
{([
|
||||
{ value: 'light', label: t('settings.light') },
|
||||
{ value: 'dark', label: t('settings.dark') },
|
||||
{ value: 'auto', label: t('settings.auto') },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={darkMode === opt.value || (opt.value === 'light' && darkMode === false) || (opt.value === 'dark' && darkMode === true)}
|
||||
onClick={() => save({ dark_mode: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Temperature */}
|
||||
<OptionRow label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
|
||||
{([
|
||||
{ value: 'celsius', label: '°C Celsius' },
|
||||
{ value: 'fahrenheit', label: '°F Fahrenheit' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={defaults.temperature_unit === opt.value}
|
||||
onClick={() => save({ temperature_unit: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Time Format */}
|
||||
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
|
||||
{([
|
||||
{ value: '24h', label: '24h (14:30)' },
|
||||
{ value: '12h', label: '12h (2:30 PM)' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={defaults.time_format === opt.value}
|
||||
onClick={() => save({ time_format: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Route Calculation */}
|
||||
<OptionRow label={<>{t('settings.routeCalculation')} <ResetButton field="route_calculation" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={String(opt.value)}
|
||||
active={defaults.route_calculation === opt.value}
|
||||
onClick={() => save({ route_calculation: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Blur Booking Codes */}
|
||||
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={String(opt.value)}
|
||||
active={defaults.blur_booking_codes === opt.value}
|
||||
onClick={() => save({ blur_booking_codes: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Map Tile URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('settings.mapTemplate')}
|
||||
<ResetButton field="map_tile_url" />
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={mapTileUrl}
|
||||
onChange={(value: string) => { if (value) { setMapTileUrl(value); save({ map_tile_url: value }) } }}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
size="sm"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapTileUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
|
||||
onBlur={() => save({ map_tile_url: mapTileUrl })}
|
||||
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{t('settings.mapDefaultHint')}</p>
|
||||
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
places: mapPreviewPlaces,
|
||||
dayPlaces: [],
|
||||
route: null,
|
||||
routeSegments: null,
|
||||
selectedPlaceId: null,
|
||||
onMarkerClick: null,
|
||||
onMapClick: null,
|
||||
onMapContextMenu: null,
|
||||
center: [48.8566, 2.3522],
|
||||
zoom: 10,
|
||||
tileUrl: mapTileUrl,
|
||||
fitKey: null,
|
||||
dayOrderMap: [],
|
||||
leftWidth: 0,
|
||||
rightWidth: 0,
|
||||
hasInspector: false,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react'
|
||||
@@ -29,54 +29,142 @@ interface TripMember {
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
interface CollabFeatures {
|
||||
chat: boolean
|
||||
notes: boolean
|
||||
polls: boolean
|
||||
whatsnext: boolean
|
||||
}
|
||||
|
||||
interface CollabPanelProps {
|
||||
tripId: number
|
||||
tripMembers?: TripMember[]
|
||||
collabFeatures?: CollabFeatures
|
||||
}
|
||||
|
||||
export default function CollabPanel({ tripId, tripMembers = [] }: CollabPanelProps) {
|
||||
const ALL_TABS = [
|
||||
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', fallback: 'Chat', icon: MessageCircle },
|
||||
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', fallback: 'Notes', icon: StickyNote },
|
||||
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', fallback: 'Polls', icon: BarChart3 },
|
||||
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', fallback: "What's Next", icon: Sparkles },
|
||||
]
|
||||
|
||||
export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }: CollabPanelProps) {
|
||||
const { user } = useAuthStore()
|
||||
const { t } = useTranslation()
|
||||
const [mobileTab, setMobileTab] = useState('chat')
|
||||
const isDesktop = useIsDesktop()
|
||||
|
||||
const tabs = [
|
||||
{ id: 'chat', label: t('collab.tabs.chat') || 'Chat', icon: MessageCircle },
|
||||
{ id: 'notes', label: t('collab.tabs.notes') || 'Notes', icon: StickyNote },
|
||||
{ id: 'polls', label: t('collab.tabs.polls') || 'Polls', icon: BarChart3 },
|
||||
{ id: 'next', label: t('collab.whatsNext.title') || "What's Next", icon: Sparkles },
|
||||
]
|
||||
const features = collabFeatures || { chat: true, notes: true, polls: true, whatsnext: true }
|
||||
|
||||
const tabs = useMemo(() =>
|
||||
ALL_TABS.filter(tab => features[tab.featureKey]).map(tab => ({
|
||||
...tab,
|
||||
label: t(tab.labelKey) || tab.fallback,
|
||||
})),
|
||||
[features, t])
|
||||
|
||||
const [mobileTab, setMobileTab] = useState(() => tabs[0]?.id || 'chat')
|
||||
|
||||
// If active tab gets disabled, switch to first available
|
||||
useEffect(() => {
|
||||
if (tabs.length > 0 && !tabs.some(t => t.id === mobileTab)) {
|
||||
setMobileTab(tabs[0].id)
|
||||
}
|
||||
}, [tabs, mobileTab])
|
||||
|
||||
const chatOn = features.chat
|
||||
const rightPanels = [
|
||||
features.notes && 'notes',
|
||||
features.polls && 'polls',
|
||||
features.whatsnext && 'whatsnext',
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
if (tabs.length === 0) return null
|
||||
|
||||
if (isDesktop) {
|
||||
// Chat always 380px fixed when on. Right panels share remaining space.
|
||||
// If chat off, all panels share full width equally.
|
||||
if (chatOn && rightPanels.length === 0) {
|
||||
// Only chat
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<CollabChat tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (chatOn) {
|
||||
// Chat left (380px) + right panels
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: '0 0 380px' }}>
|
||||
<CollabChat tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{rightPanels.length === 1 && (
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
{rightPanels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{rightPanels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{rightPanels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
)}
|
||||
{rightPanels.length === 2 && rightPanels.map(p => (
|
||||
<div key={p} style={{ ...card, flex: 1 }}>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
))}
|
||||
{rightPanels.length === 3 && (
|
||||
<>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<CollabNotes tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<CollabPolls tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<WhatsNextWidget tripMembers={tripMembers} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Chat off — remaining panels share full width
|
||||
const panels = rightPanels
|
||||
if (panels.length === 1) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
{panels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{panels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{panels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{/* Chat — left, fixed width */}
|
||||
<div style={{ ...card, flex: '0 0 380px' }}>
|
||||
<CollabChat tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
|
||||
{/* Right column: Notes top, Polls + What's Next bottom */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{/* Notes — top */}
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<CollabNotes tripId={tripId} currentUser={user} />
|
||||
{panels.map(p => (
|
||||
<div key={p} style={{ ...card, flex: 1 }}>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
|
||||
{/* Polls + What's Next — bottom row */}
|
||||
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<CollabPolls tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<WhatsNextWidget tripMembers={tripMembers} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Mobile: tab bar + single panel
|
||||
// Mobile: tab bar + single panel (only enabled tabs)
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'absolute', inset: 0 }}>
|
||||
<div style={{
|
||||
@@ -84,7 +172,6 @@ export default function CollabPanel({ tripId, tripMembers = [] }: CollabPanelPro
|
||||
background: 'var(--bg-card)', flexShrink: 0,
|
||||
}}>
|
||||
{tabs.map(tab => {
|
||||
const Icon = tab.icon
|
||||
const active = mobileTab === tab.id
|
||||
return (
|
||||
<button key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
|
||||
@@ -102,10 +189,10 @@ export default function CollabPanel({ tripId, tripMembers = [] }: CollabPanelPro
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||
{mobileTab === 'chat' && <CollabChat tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'next' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
{mobileTab === 'chat' && features.chat && <CollabChat tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'notes' && features.notes && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'polls' && features.polls && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'next' && features.whatsnext && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -94,7 +94,7 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column' }}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
onClick={onClose}
|
||||
onTouchStart={e => setTouchStart(e.touches[0].clientX)}
|
||||
onTouchEnd={e => {
|
||||
|
||||
@@ -33,6 +33,8 @@ interface Props {
|
||||
dark?: boolean
|
||||
activeMarkerId?: string | null
|
||||
onMarkerClick?: (id: string, type?: string) => void
|
||||
fullScreen?: boolean
|
||||
paddingBottom?: number
|
||||
}
|
||||
|
||||
function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
||||
@@ -57,15 +59,20 @@ const MARKER_W = 28
|
||||
const MARKER_H = 36
|
||||
|
||||
function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
|
||||
// Highlighted: inverted colors for contrast (black on light, white on dark)
|
||||
const fill = dark
|
||||
? (highlighted ? '#FAFAFA' : '#FAFAFA')
|
||||
: (highlighted ? '#18181B' : '#18181B')
|
||||
? (highlighted ? '#FAFAFA' : '#A1A1AA')
|
||||
: (highlighted ? '#18181B' : '#52525B')
|
||||
const textColor = dark
|
||||
? (highlighted ? '#18181B' : '#18181B')
|
||||
: (highlighted ? '#fff' : '#fff')
|
||||
const stroke = dark ? '#3F3F46' : '#fff'
|
||||
const stroke = highlighted
|
||||
? (dark ? '#fff' : '#18181B')
|
||||
: (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))'
|
||||
? (dark
|
||||
? 'filter:drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
||||
: 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.3)) 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
|
||||
@@ -82,7 +89,7 @@ function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
|
||||
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
|
||||
|
||||
const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick },
|
||||
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom },
|
||||
ref
|
||||
) {
|
||||
const stableTrail = trail || EMPTY_TRAIL
|
||||
@@ -138,7 +145,9 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
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 })
|
||||
try {
|
||||
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
|
||||
} catch { /* map not yet initialized */ }
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -156,7 +165,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
const map = L.map(containerRef.current, {
|
||||
zoomControl: false,
|
||||
attributionControl: true,
|
||||
scrollWheelZoom: false,
|
||||
scrollWheelZoom: fullScreen ? true : false,
|
||||
dragging: true,
|
||||
touchZoom: true,
|
||||
})
|
||||
@@ -185,8 +194,8 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
coords.forEach(c => allCoords.push(c))
|
||||
}
|
||||
|
||||
// route polyline — subtle dashed connection
|
||||
if (items.length > 1) {
|
||||
// route polyline — only in non-fullscreen (sidebar map) mode
|
||||
if (!fullScreen && items.length > 1) {
|
||||
const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple)
|
||||
L.polyline(routeCoords, {
|
||||
color: dark ? '#71717A' : '#A1A1AA',
|
||||
@@ -229,7 +238,8 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
try {
|
||||
map.invalidateSize()
|
||||
if (allCoords.length > 0) {
|
||||
map.fitBounds(L.latLngBounds(allCoords), { padding: [50, 50], maxZoom: 14 })
|
||||
const pb = paddingBottom || 50
|
||||
map.fitBounds(L.latLngBounds(allCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 14 })
|
||||
} else {
|
||||
map.setView([30, 0], 2)
|
||||
}
|
||||
@@ -245,7 +255,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
mapRef.current = null
|
||||
markersRef.current.clear()
|
||||
}
|
||||
}, [entries, stableTrail, dark, mapTileUrl])
|
||||
}, [entries, stableTrail, dark, mapTileUrl, fullScreen, paddingBottom])
|
||||
|
||||
// react to activeMarkerId prop changes — runs after map is built
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react'
|
||||
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||
|
||||
const MOOD_ICONS: Record<string, typeof Smile> = {
|
||||
amazing: Laugh,
|
||||
good: Smile,
|
||||
neutral: Meh,
|
||||
rough: Frown,
|
||||
}
|
||||
|
||||
const MOOD_COLORS: Record<string, string> = {
|
||||
amazing: 'text-pink-500',
|
||||
good: 'text-amber-500',
|
||||
neutral: 'text-zinc-400',
|
||||
rough: 'text-violet-500',
|
||||
}
|
||||
|
||||
const WEATHER_ICONS: Record<string, typeof Sun> = {
|
||||
sunny: Sun,
|
||||
partly: CloudSun,
|
||||
cloudy: Cloud,
|
||||
rainy: CloudRain,
|
||||
stormy: CloudLightning,
|
||||
cold: Snowflake,
|
||||
}
|
||||
|
||||
function photoUrl(p: JourneyPhoto): string {
|
||||
return `/api/photos/${p.photo_id}/thumbnail`
|
||||
}
|
||||
|
||||
function stripMarkdown(text: string): string {
|
||||
return text
|
||||
.replace(/[#*_~`>\[\]()!|-]/g, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entry: JourneyEntry | { id: number; type: string; title?: string | null; location_name?: string | null; location_lat?: number | null; location_lng?: number | null; entry_date: string; entry_time?: string | null; mood?: string | null; weather?: string | null; photos?: { photo_id: number }[]; story?: string | null }
|
||||
index: number
|
||||
isActive: boolean
|
||||
onClick: () => void
|
||||
publicPhotoUrl?: (photoId: number) => string
|
||||
}
|
||||
|
||||
export default function MobileEntryCard({ entry, index, isActive, onClick, publicPhotoUrl }: Props) {
|
||||
const hasLocation = !!(entry.location_lat && entry.location_lng)
|
||||
const hasPhotos = entry.photos && entry.photos.length > 0
|
||||
const firstPhoto = hasPhotos ? entry.photos![0] : null
|
||||
const MoodIcon = entry.mood ? MOOD_ICONS[entry.mood] : null
|
||||
const moodColor = entry.mood ? MOOD_COLORS[entry.mood] : ''
|
||||
const WeatherIcon = entry.weather ? WEATHER_ICONS[entry.weather] : null
|
||||
|
||||
const thumbSrc = firstPhoto
|
||||
? publicPhotoUrl
|
||||
? publicPhotoUrl((firstPhoto as any).photo_id ?? (firstPhoto as any).id)
|
||||
: photoUrl(firstPhoto as JourneyPhoto)
|
||||
: null
|
||||
|
||||
const date = new Date(entry.entry_date + 'T00:00:00')
|
||||
const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
|
||||
const storyPreview = entry.story ? stripMarkdown(entry.story) : ''
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex-shrink-0 rounded-xl overflow-hidden text-left transition-all duration-100 ${
|
||||
isActive
|
||||
? 'w-[320px] sm:w-[340px] bg-white dark:bg-zinc-800 shadow-lg ring-2 ring-zinc-900/70 dark:ring-white/60'
|
||||
: 'w-[240px] sm:w-[260px] bg-white/90 dark:bg-zinc-800/90 shadow-md'
|
||||
} backdrop-blur-lg`}
|
||||
>
|
||||
<div className={`flex ${isActive ? 'h-[140px]' : 'h-[110px]'} transition-all duration-100`}>
|
||||
{/* Photo thumbnail */}
|
||||
{thumbSrc ? (
|
||||
<div className={`${isActive ? 'w-[110px]' : 'w-[90px]'} flex-shrink-0 relative overflow-hidden transition-all duration-100`}>
|
||||
<img
|
||||
src={thumbSrc}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{hasPhotos && entry.photos!.length > 1 && (
|
||||
<div className="absolute bottom-1 right-1 flex items-center gap-0.5 bg-black/60 text-white rounded px-1 py-0.5 text-[10px] font-medium">
|
||||
<Camera size={10} />
|
||||
{entry.photos!.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`${isActive ? 'w-[110px]' : 'w-[90px]'} flex-shrink-0 bg-zinc-100 dark:bg-zinc-700 flex items-center justify-center transition-all duration-100`}>
|
||||
<MapPin size={20} className="text-zinc-300 dark:text-zinc-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 p-3 flex flex-col min-w-0">
|
||||
{/* Day number + date + mood/weather */}
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className="w-5 h-5 rounded bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] font-bold flex items-center justify-center flex-shrink-0">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span>
|
||||
{entry.entry_time && (
|
||||
<span className="text-[11px] text-zinc-400">· {entry.entry_time.slice(0, 5)}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 ml-auto flex-shrink-0">
|
||||
{MoodIcon && (
|
||||
<span className={`inline-flex items-center justify-center w-5 h-5 rounded-full ${
|
||||
entry.mood === 'amazing' ? 'bg-pink-100 dark:bg-pink-900/30' :
|
||||
entry.mood === 'good' ? 'bg-amber-100 dark:bg-amber-900/30' :
|
||||
entry.mood === 'rough' ? 'bg-violet-100 dark:bg-violet-900/30' :
|
||||
'bg-zinc-100 dark:bg-zinc-700'
|
||||
}`}>
|
||||
<MoodIcon size={11} className={moodColor} />
|
||||
</span>
|
||||
)}
|
||||
{WeatherIcon && (
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-zinc-100 dark:bg-zinc-700">
|
||||
<WeatherIcon size={11} className="text-zinc-500 dark:text-zinc-400" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h4 className="text-[13px] font-semibold text-zinc-900 dark:text-white leading-tight truncate">
|
||||
{entry.title || (entry.type === 'checkin' ? 'Check-in' : entry.type === 'skeleton' ? 'Add your story…' : 'Untitled')}
|
||||
</h4>
|
||||
|
||||
{/* Story preview (1-2 lines, only on active card) */}
|
||||
{isActive && storyPreview && (
|
||||
<p className="text-[11px] text-zinc-400 dark:text-zinc-500 leading-snug mt-0.5 line-clamp-2">
|
||||
{storyPreview}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Location badge */}
|
||||
<div className="flex items-center gap-1 mt-auto">
|
||||
{hasLocation ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-700 text-[10px] font-medium text-zinc-600 dark:text-zinc-300 max-w-full overflow-hidden">
|
||||
<MapPin size={10} className="flex-shrink-0" />
|
||||
<span className="truncate">{entry.location_name || 'On the map'}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-zinc-400 italic">No location</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
X, Pencil, Trash2, MapPin, Clock, Camera,
|
||||
Laugh, Smile, Meh, Frown,
|
||||
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
|
||||
ThumbsUp, ThumbsDown, ChevronDown,
|
||||
} from 'lucide-react'
|
||||
import JournalBody from './JournalBody'
|
||||
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||
|
||||
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
|
||||
amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' },
|
||||
good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' },
|
||||
neutral: { icon: Meh, label: 'Neutral', bg: 'bg-zinc-100 dark:bg-zinc-800', text: 'text-zinc-500 dark:text-zinc-400' },
|
||||
rough: { icon: Frown, label: 'Rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' },
|
||||
}
|
||||
|
||||
const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
|
||||
sunny: { icon: Sun, label: 'Sunny' },
|
||||
partly: { icon: CloudSun, label: 'Partly cloudy' },
|
||||
cloudy: { icon: Cloud, label: 'Cloudy' },
|
||||
rainy: { icon: CloudRain, label: 'Rainy' },
|
||||
stormy: { icon: CloudLightning, label: 'Stormy' },
|
||||
cold: { icon: Snowflake, label: 'Cold' },
|
||||
}
|
||||
|
||||
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original'): string {
|
||||
return `/api/photos/${p.photo_id}/${size}`
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entry: JourneyEntry
|
||||
onClose: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
||||
}
|
||||
|
||||
export default function MobileEntryView({ entry, onClose, onEdit, onDelete, onPhotoClick }: Props) {
|
||||
const photos = entry.photos || []
|
||||
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
|
||||
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
|
||||
const prosArr = entry.pros_cons?.pros ?? []
|
||||
const consArr = entry.pros_cons?.cons ?? []
|
||||
const hasProscons = prosArr.length > 0 || consArr.length > 0
|
||||
|
||||
const date = new Date(entry.entry_date + 'T00:00:00')
|
||||
const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
|
||||
{/* Top bar */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-zinc-800 flex-shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => { onClose(); onEdit(); }}
|
||||
className="h-8 px-3 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 text-[12px] font-medium flex items-center gap-1.5 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onClose(); onDelete(); }}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain" style={{ WebkitOverflowScrolling: 'touch' }}>
|
||||
|
||||
{/* Hero photo(s) */}
|
||||
{photos.length > 0 && (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={photoUrl(photos[0])}
|
||||
alt=""
|
||||
className="w-full max-h-[50vh] object-cover cursor-pointer"
|
||||
onClick={() => onPhotoClick(photos, 0)}
|
||||
/>
|
||||
{photos.length > 1 && (
|
||||
<div className="absolute bottom-3 right-3 flex items-center gap-1 bg-black/60 backdrop-blur-sm text-white rounded-full px-2.5 py-1 text-[11px] font-medium">
|
||||
<Camera size={12} />
|
||||
{photos.length} photos
|
||||
</div>
|
||||
)}
|
||||
{/* Photo strip for multiple photos */}
|
||||
{photos.length > 1 && (
|
||||
<div className="flex gap-1 px-4 py-2 overflow-x-auto bg-zinc-50 dark:bg-zinc-900">
|
||||
{photos.map((p, i) => (
|
||||
<img
|
||||
key={p.id || i}
|
||||
src={photoUrl(p, 'thumbnail')}
|
||||
alt=""
|
||||
className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all"
|
||||
onClick={() => onPhotoClick(photos, i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-5 py-5 pb-32">
|
||||
|
||||
{/* Date + time + location header */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<span className="text-[12px] font-medium text-zinc-500">{dateStr}</span>
|
||||
{entry.entry_time && (
|
||||
<span className="flex items-center gap-1 text-[12px] text-zinc-400">
|
||||
<Clock size={11} />
|
||||
{entry.entry_time.slice(0, 5)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{entry.location_name && (
|
||||
<div className="mb-3">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[12px] font-medium text-zinc-700 dark:text-zinc-300">
|
||||
<MapPin size={12} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
|
||||
{entry.location_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
{entry.title && (
|
||||
<h1 className="text-[22px] font-bold text-zinc-900 dark:text-white tracking-tight leading-tight mb-4">
|
||||
{entry.title}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{/* Mood + Weather chips */}
|
||||
{(mood || weather) && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{mood && (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold ${mood.bg} ${mood.text}`}>
|
||||
<mood.icon size={13} />
|
||||
{mood.label}
|
||||
</span>
|
||||
)}
|
||||
{weather && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400">
|
||||
<weather.icon size={13} />
|
||||
{weather.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Story */}
|
||||
{entry.story && (
|
||||
<div className="text-[14px] leading-relaxed text-zinc-700 dark:text-zinc-300 mb-5">
|
||||
<JournalBody text={entry.story} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{entry.tags && entry.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-5">
|
||||
{entry.tags.map((tag, i) => (
|
||||
<span key={i} className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pros & Cons */}
|
||||
{hasProscons && (
|
||||
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden mb-5">
|
||||
{prosArr.length > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400 uppercase tracking-wide mb-2">
|
||||
<ThumbsUp size={12} /> Pros
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{prosArr.map((p, i) => (
|
||||
<li key={i} className="text-[13px] text-zinc-700 dark:text-zinc-300 flex items-start gap-2">
|
||||
<span className="text-emerald-500 mt-0.5">+</span> {p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{prosArr.length > 0 && consArr.length > 0 && (
|
||||
<div className="border-t border-zinc-200 dark:border-zinc-700" />
|
||||
)}
|
||||
{consArr.length > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-red-500 dark:text-red-400 uppercase tracking-wide mb-2">
|
||||
<ThumbsDown size={12} /> Cons
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{consArr.map((c, i) => (
|
||||
<li key={i} className="text-[13px] text-zinc-700 dark:text-zinc-300 flex items-start gap-2">
|
||||
<span className="text-red-500 mt-0.5">−</span> {c}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useRef, useState, useEffect, useCallback } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import JourneyMap from './JourneyMap'
|
||||
import MobileEntryCard from './MobileEntryCard'
|
||||
import type { JourneyMapHandle } from './JourneyMap'
|
||||
import type { JourneyEntry } from '../../store/journeyStore'
|
||||
|
||||
interface MapEntry {
|
||||
id: string
|
||||
lat: number
|
||||
lng: number
|
||||
title?: string | null
|
||||
mood?: string | null
|
||||
entry_date: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entries: JourneyEntry[] | any[]
|
||||
mapEntries: MapEntry[]
|
||||
trail?: { lat: number; lng: number }[]
|
||||
dark?: boolean
|
||||
readOnly?: boolean
|
||||
onEntryClick: (entry: any) => void
|
||||
onAddEntry?: () => void
|
||||
publicPhotoUrl?: (photoId: number) => string
|
||||
}
|
||||
|
||||
export default function MobileMapTimeline({
|
||||
entries,
|
||||
mapEntries,
|
||||
trail,
|
||||
dark,
|
||||
readOnly,
|
||||
onEntryClick,
|
||||
onAddEntry,
|
||||
publicPhotoUrl,
|
||||
}: Props) {
|
||||
const mapRef = useRef<JourneyMapHandle>(null)
|
||||
const carouselRef = useRef<HTMLDivElement>(null)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
||||
|
||||
// Sync map focus when carousel scrolls (with guard for uninitialized map)
|
||||
const syncMapToCarousel = useCallback((index: number) => {
|
||||
const entry = entries[index]
|
||||
if (!entry) return
|
||||
|
||||
const mapEntry = mapEntries.find(m => String(m.id) === String(entry.id))
|
||||
if (mapEntry) {
|
||||
try { mapRef.current?.focusMarker(String(mapEntry.id)) } catch {}
|
||||
} else {
|
||||
try { mapRef.current?.highlightMarker(null) } catch {}
|
||||
}
|
||||
}, [entries, mapEntries])
|
||||
|
||||
// IntersectionObserver for instant snap detection
|
||||
useEffect(() => {
|
||||
const el = carouselRef.current
|
||||
if (!el || entries.length === 0) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(observed) => {
|
||||
for (const o of observed) {
|
||||
if (o.isIntersecting) {
|
||||
const idx = Number(o.target.getAttribute('data-idx'))
|
||||
if (!isNaN(idx)) {
|
||||
setActiveIndex(idx)
|
||||
syncMapToCarousel(idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ root: el, threshold: 0.6 },
|
||||
)
|
||||
|
||||
cardRefs.current.forEach(node => observer.observe(node))
|
||||
return () => observer.disconnect()
|
||||
}, [entries.length, syncMapToCarousel])
|
||||
|
||||
// Scroll carousel to entry when map marker is clicked
|
||||
const handleMarkerClick = useCallback((id: string) => {
|
||||
const idx = entries.findIndex((e: any) => String(e.id) === id)
|
||||
if (idx === -1) return
|
||||
setActiveIndex(idx)
|
||||
|
||||
const el = carouselRef.current
|
||||
if (!el) return
|
||||
const cardWidth = 272
|
||||
el.scrollTo({ left: idx * cardWidth, behavior: 'smooth' })
|
||||
}, [entries])
|
||||
|
||||
// Initial map focus — delay to let Leaflet initialize and fitBounds
|
||||
useEffect(() => {
|
||||
if (entries.length > 0) {
|
||||
const timer = setTimeout(() => syncMapToCarousel(0), 500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [entries.length])
|
||||
|
||||
const activeEntryId = entries[activeIndex]
|
||||
? String(entries[activeIndex].id)
|
||||
: null
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
|
||||
<JourneyMap
|
||||
ref={mapRef}
|
||||
entries={mapEntries}
|
||||
checkins={[]}
|
||||
trail={trail}
|
||||
height={9999}
|
||||
dark={dark}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
fullScreen
|
||||
/>
|
||||
{!readOnly && onAddEntry && (
|
||||
<div className="fixed top-[calc(var(--nav-h,56px)+12px)] right-4 z-30">
|
||||
<button
|
||||
onClick={onAddEntry}
|
||||
className="w-10 h-10 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
|
||||
{/* Full-screen map */}
|
||||
<JourneyMap
|
||||
ref={mapRef}
|
||||
entries={mapEntries}
|
||||
checkins={[]}
|
||||
trail={trail}
|
||||
height={9999}
|
||||
dark={dark}
|
||||
activeMarkerId={activeEntryId}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
fullScreen
|
||||
paddingBottom={200}
|
||||
/>
|
||||
|
||||
{/* Bottom carousel */}
|
||||
<div
|
||||
className="fixed bottom-20 left-0 right-0 z-40"
|
||||
style={{ touchAction: 'pan-x' }}
|
||||
>
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1 scroll-smooth"
|
||||
style={{
|
||||
scrollSnapType: 'x mandatory',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
}}
|
||||
>
|
||||
{entries.map((entry: any, i: number) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
data-idx={i}
|
||||
ref={node => { if (node) cardRefs.current.set(i, node); else cardRefs.current.delete(i); }}
|
||||
style={{ scrollSnapAlign: 'center' }}
|
||||
>
|
||||
<MobileEntryCard
|
||||
entry={entry}
|
||||
index={i}
|
||||
isActive={i === activeIndex}
|
||||
onClick={() => onEntryClick(entry)}
|
||||
publicPhotoUrl={publicPhotoUrl}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAB: add entry — top right */}
|
||||
{!readOnly && onAddEntry && (
|
||||
<div className="fixed top-[calc(var(--nav-h,56px)+12px)] right-4 z-30">
|
||||
<button
|
||||
onClick={onAddEntry}
|
||||
className="w-10 h-10 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -69,6 +69,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
|
||||
position: 'fixed', inset: 0, zIndex: 500,
|
||||
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
paddingBottom: 'var(--bottom-nav-h)',
|
||||
}}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
|
||||
// Album linking
|
||||
const [showAlbumPicker, setShowAlbumPicker] = useState(false)
|
||||
const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number }[]>([])
|
||||
const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number; passphrase?: string }[]>([])
|
||||
const [albumsLoading, setAlbumsLoading] = useState(false)
|
||||
const [albumLinks, setAlbumLinks] = useState<{ id: number; provider: string; album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([])
|
||||
const [syncing, setSyncing] = useState<number | null>(null)
|
||||
@@ -141,7 +141,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
await loadAlbums(selectedProvider)
|
||||
}
|
||||
|
||||
const linkAlbum = async (albumId: string, albumName: string) => {
|
||||
const linkAlbum = async (albumId: string, albumName: string, passphrase?: string) => {
|
||||
if (!selectedProvider) {
|
||||
toast.error(t('memories.error.linkAlbum'))
|
||||
return
|
||||
@@ -152,6 +152,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
album_id: albumId,
|
||||
album_name: albumName,
|
||||
provider: selectedProvider,
|
||||
...(passphrase ? { passphrase } : {}),
|
||||
})
|
||||
setShowAlbumPicker(false)
|
||||
await loadAlbumLinks()
|
||||
@@ -489,7 +490,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
{albums.map(album => {
|
||||
const isLinked = linkedIds.has(album.id)
|
||||
return (
|
||||
<button key={album.id} onClick={() => !isLinked && linkAlbum(album.id, album.albumName)}
|
||||
<button key={album.id} onClick={() => !isLinked && linkAlbum(album.id, album.albumName, album.passphrase)}
|
||||
disabled={isLinked}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// FE-COMP-JOURNEYPDF-001 to FE-COMP-JOURNEYPDF-006
|
||||
//
|
||||
// JourneyBookPDF.tsx exports an async function `downloadJourneyBookPDF(journey)`
|
||||
// that opens a new browser window and writes a full HTML document into it.
|
||||
// It does NOT render a React component. Tests verify window.open behaviour.
|
||||
// that renders a PDF preview in an srcdoc iframe overlay (Safari-safe pattern).
|
||||
// Tests verify the overlay DOM structure and HTML content.
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
|
||||
@@ -77,55 +77,57 @@ function buildJourney(overrides: Partial<JourneyDetail> = {}): JourneyDetail {
|
||||
} as unknown as JourneyDetail;
|
||||
}
|
||||
|
||||
// ── Mock window.open ─────────────────────────────────────────────────────────
|
||||
// ── Helpers to inspect the overlay ───────────────────────────────────────────
|
||||
|
||||
let mockWindow: {
|
||||
document: { write: ReturnType<typeof vi.fn>; close: ReturnType<typeof vi.fn> };
|
||||
focus: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
function getOverlay(): HTMLElement | null {
|
||||
return document.getElementById('journey-pdf-overlay');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockWindow = {
|
||||
document: { write: vi.fn(), close: vi.fn() },
|
||||
focus: vi.fn(),
|
||||
};
|
||||
vi.spyOn(window, 'open').mockReturnValue(mockWindow as any);
|
||||
});
|
||||
function getIframe(): HTMLIFrameElement | null {
|
||||
return getOverlay()?.querySelector('iframe') ?? null;
|
||||
}
|
||||
|
||||
// ── Setup ────────────────────────────────────────────────────────────────────
|
||||
|
||||
afterEach(() => {
|
||||
document.getElementById('journey-pdf-overlay')?.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('downloadJourneyBookPDF', () => {
|
||||
it('FE-COMP-JOURNEYPDF-001: opens a new window', async () => {
|
||||
it('FE-COMP-JOURNEYPDF-001: appends overlay to document body', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
expect(window.open).toHaveBeenCalledWith('', '_blank');
|
||||
expect(getOverlay()).not.toBeNull();
|
||||
expect(document.body.contains(getOverlay())).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-002: writes HTML to the new window', async () => {
|
||||
it('FE-COMP-JOURNEYPDF-002: overlay contains an iframe with srcdoc HTML', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
expect(mockWindow.document.write).toHaveBeenCalledTimes(1);
|
||||
const html = mockWindow.document.write.mock.calls[0][0] as string;
|
||||
const iframe = getIframe();
|
||||
expect(iframe).not.toBeNull();
|
||||
const html = iframe!.srcdoc;
|
||||
expect(html).toContain('<!DOCTYPE html>');
|
||||
expect(html).toContain('</html>');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-003: closes the document after writing', async () => {
|
||||
it('FE-COMP-JOURNEYPDF-003: overlay has close and save buttons', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
expect(mockWindow.document.close).toHaveBeenCalledTimes(1);
|
||||
const overlay = getOverlay()!;
|
||||
expect(overlay.querySelector('#journey-pdf-close')).not.toBeNull();
|
||||
expect(overlay.querySelector('#journey-pdf-save')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-004: HTML contains the journey title', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
const html = mockWindow.document.write.mock.calls[0][0] as string;
|
||||
const html = getIframe()!.srcdoc;
|
||||
expect(html).toContain('Iceland Ring Road');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-005: HTML contains entry content', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
const html = mockWindow.document.write.mock.calls[0][0] as string;
|
||||
const html = getIframe()!.srcdoc;
|
||||
expect(html).toContain('Golden Circle');
|
||||
// Story text is rendered via markdown
|
||||
expect(html).toContain('An incredible day of geysers and waterfalls.');
|
||||
@@ -137,8 +139,8 @@ describe('downloadJourneyBookPDF', () => {
|
||||
it('FE-COMP-JOURNEYPDF-006: handles empty entries gracefully', async () => {
|
||||
const journey = buildJourney({ entries: [] });
|
||||
await downloadJourneyBookPDF(journey);
|
||||
expect(window.open).toHaveBeenCalled();
|
||||
const html = mockWindow.document.write.mock.calls[0][0] as string;
|
||||
expect(getOverlay()).not.toBeNull();
|
||||
const html = getIframe()!.srcdoc;
|
||||
expect(html).toContain('Iceland Ring Road');
|
||||
// No entry pages, but cover and closing page are still present
|
||||
expect(html).toContain('Journey Book');
|
||||
|
||||
@@ -249,23 +249,9 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
|
||||
.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">
|
||||
@@ -299,8 +285,37 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const win = window.open('', '_blank')
|
||||
if (!win) return
|
||||
win.document.write(html)
|
||||
win.document.close()
|
||||
// Render in a fixed overlay + srcdoc iframe — same pattern as TripPDF.
|
||||
// This avoids window.open() which Safari iOS blocks in async callbacks
|
||||
// and window.close() which doesn't work reliably in standalone PWA mode.
|
||||
const overlay = document.createElement('div')
|
||||
overlay.id = 'journey-pdf-overlay'
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
|
||||
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() }
|
||||
|
||||
const card = document.createElement('div')
|
||||
card.style.cssText = 'width:100%;max-width:1100px;height:95vh;background:#fff;border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.35);'
|
||||
|
||||
const header = document.createElement('div')
|
||||
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:8px 16px;border-bottom:1px solid #e4e4e7;flex-shrink:0;background:#0f172a;'
|
||||
header.innerHTML = `
|
||||
<span style="font-size:12px;color:rgba(255,255,255,0.45);font-weight:500;letter-spacing:0.03em">${esc(journey.title)} · ${totalPages} pages</span>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<button id="journey-pdf-save" style="min-height:44px;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;border:none;background:#fff;color:#0f172a;">Save as PDF</button>
|
||||
<button id="journey-pdf-close" style="min-height:44px;padding:10px 16px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.1);color:rgba(255,255,255,0.7);">Close</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.style.cssText = 'flex:1;width:100%;border:none;'
|
||||
iframe.sandbox = 'allow-same-origin allow-modals'
|
||||
iframe.srcdoc = html
|
||||
|
||||
card.appendChild(header)
|
||||
card.appendChild(iframe)
|
||||
overlay.appendChild(card)
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
header.querySelector<HTMLButtonElement>('#journey-pdf-close')!.onclick = () => overlay.remove()
|
||||
header.querySelector<HTMLButtonElement>('#journey-pdf-save')!.onclick = () => { iframe.contentWindow?.print() }
|
||||
}
|
||||
|
||||
@@ -1268,7 +1268,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
|
||||
{/* ── Bag Modal (mobile + click) ── */}
|
||||
{showBagModal && bagTrackingEnabled && (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, overflowY: 'auto' }}
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflowY: 'auto' }}
|
||||
onClick={() => setShowBagModal(false)}>
|
||||
<div style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 360, maxHeight: 'calc(100vh - 80px)', overflow: 'auto', padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.15)', flexShrink: 0 }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
|
||||
@@ -79,6 +79,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
|
||||
style={{ paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Main area */}
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
const [showHotelPicker, setShowHotelPicker] = useState(false)
|
||||
const [hotelDayRange, setHotelDayRange] = useState({ start: day?.id, end: day?.id })
|
||||
const [hotelCategoryFilter, setHotelCategoryFilter] = useState('')
|
||||
const [hotelForm, setHotelForm] = useState({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
||||
const [hotelForm, setHotelForm] = useState({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
|
||||
|
||||
useEffect(() => {
|
||||
if (!day?.date || !lat || !lng) { setWeather(null); return }
|
||||
@@ -117,6 +117,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
start_day_id: hotelDayRange.start,
|
||||
end_day_id: hotelDayRange.end,
|
||||
check_in: hotelForm.check_in || null,
|
||||
check_in_end: hotelForm.check_in_end || null,
|
||||
check_out: hotelForm.check_out || null,
|
||||
confirmation: hotelForm.confirmation || null,
|
||||
})
|
||||
@@ -128,7 +129,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
||||
))
|
||||
setShowHotelPicker(false)
|
||||
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
||||
setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
|
||||
onAccommodationChange?.()
|
||||
} catch {}
|
||||
}
|
||||
@@ -356,7 +357,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_name}</div>
|
||||
{acc.place_address && <div style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_address}</div>}
|
||||
</div>
|
||||
{canEditDays && <button onClick={() => { setAccommodation(acc); setHotelForm({ check_in: acc.check_in || '', check_out: acc.check_out || '', confirmation: acc.confirmation || '', place_id: acc.place_id }); setHotelDayRange({ start: acc.start_day_id, end: acc.end_day_id }); setShowHotelPicker('edit') }}
|
||||
{canEditDays && <button onClick={() => { setAccommodation(acc); setHotelForm({ check_in: acc.check_in || '', check_in_end: acc.check_in_end || '', check_out: acc.check_out || '', confirmation: acc.confirmation || '', place_id: acc.place_id }); setHotelDayRange({ start: acc.start_day_id, end: acc.end_day_id }); setShowHotelPicker('edit') }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
|
||||
<Pencil size={12} style={{ color: 'var(--text-faint)' }} />
|
||||
</button>}
|
||||
@@ -368,7 +369,9 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
<div style={{ display: 'flex', gap: 0, margin: '0 12px 8px', borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border-faint)' }}>
|
||||
{acc.check_in && (
|
||||
<div style={{ flex: 1, padding: '8px 10px', borderRight: '1px solid var(--border-faint)', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{fmtTime(acc.check_in)}</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>
|
||||
{fmtTime(acc.check_in)}{acc.check_in_end ? ` – ${fmtTime(acc.check_in_end)}` : ''}
|
||||
</div>
|
||||
<div style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
|
||||
<LogIn size={8} /> {t('day.checkIn')}
|
||||
</div>
|
||||
@@ -488,11 +491,15 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
|
||||
{/* Check-in / Check-out / Confirmation */}
|
||||
<div style={{ padding: '10px 18px', borderBottom: '1px solid var(--border-faint)', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: 1, minWidth: 100 }}>
|
||||
<div style={{ flex: 1, minWidth: 80 }}>
|
||||
<label style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'block', marginBottom: 3 }}>{t('day.checkIn')}</label>
|
||||
<CustomTimePicker value={hotelForm.check_in} onChange={v => setHotelForm(f => ({ ...f, check_in: v }))} placeholder="14:00" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 100 }}>
|
||||
<div style={{ flex: 1, minWidth: 80 }}>
|
||||
<label style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'block', marginBottom: 3 }}>{t('day.checkInUntil')}</label>
|
||||
<CustomTimePicker value={hotelForm.check_in_end} onChange={v => setHotelForm(f => ({ ...f, check_in_end: v }))} placeholder="22:00" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 80 }}>
|
||||
<label style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'block', marginBottom: 3 }}>{t('day.checkOut')}</label>
|
||||
<CustomTimePicker value={hotelForm.check_out} onChange={v => setHotelForm(f => ({ ...f, check_out: v }))} placeholder="11:00" />
|
||||
</div>
|
||||
@@ -570,11 +577,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
start_day_id: hotelDayRange.start,
|
||||
end_day_id: hotelDayRange.end,
|
||||
check_in: hotelForm.check_in || null,
|
||||
check_in_end: hotelForm.check_in_end || null,
|
||||
check_out: hotelForm.check_out || null,
|
||||
confirmation: hotelForm.confirmation || null,
|
||||
})
|
||||
setShowHotelPicker(false)
|
||||
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
||||
setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
|
||||
// Reload
|
||||
accommodationsApi.list(tripId).then(d => {
|
||||
const all = d.accommodations || []
|
||||
|
||||
@@ -1023,7 +1023,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
</div>
|
||||
|
||||
{/* Tagesliste */}
|
||||
<div className="scroll-container" style={{ flex: 1, overflowY: 'auto', minHeight: 0, scrollbarWidth: 'thin', scrollbarColor: 'var(--scrollbar-thumb) transparent' }}>
|
||||
<div className="scroll-container" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
{days.map((day, index) => {
|
||||
const isSelected = selectedDayId === day.id
|
||||
const isExpanded = expandedDays.has(day.id)
|
||||
|
||||
@@ -473,14 +473,14 @@ describe('Google Maps list import', () => {
|
||||
it('FE-PLANNER-SIDEBAR-040: "Google List" button opens the URL dialog', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlacesSidebar {...defaultProps} />);
|
||||
await user.click(screen.getByText(/Google List/i));
|
||||
await user.click(screen.getByText(/List Import/i));
|
||||
expect(await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-041: import button disabled when URL input is empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlacesSidebar {...defaultProps} />);
|
||||
await user.click(screen.getByText(/Google List/i));
|
||||
await user.click(screen.getByText(/List Import/i));
|
||||
await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i);
|
||||
const importBtn = screen.getByRole('button', { name: /^Import$/i });
|
||||
expect(importBtn).toBeDisabled();
|
||||
@@ -498,7 +498,7 @@ describe('Google Maps list import', () => {
|
||||
(window as any).__addToast = addToast;
|
||||
const user = userEvent.setup();
|
||||
render(<PlacesSidebar {...defaultProps} pushUndo={vi.fn()} />);
|
||||
await user.click(screen.getByText(/Google List/i));
|
||||
await user.click(screen.getByText(/List Import/i));
|
||||
const urlInput = await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i);
|
||||
await user.type(urlInput, 'https://maps.app.goo.gl/abc123');
|
||||
await user.click(screen.getByRole('button', { name: /^Import$/i }));
|
||||
@@ -527,7 +527,7 @@ describe('Google Maps list import', () => {
|
||||
(window as any).__addToast = addToast;
|
||||
const user = userEvent.setup();
|
||||
render(<PlacesSidebar {...defaultProps} pushUndo={vi.fn()} />);
|
||||
await user.click(screen.getByText(/Google List/i));
|
||||
await user.click(screen.getByText(/List Import/i));
|
||||
const urlInput = await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i);
|
||||
await user.type(urlInput, 'https://maps.app.goo.gl/xyz{Enter}');
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||
import { placesApi } from '../../api/client'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||
import FileImportModal from './FileImportModal'
|
||||
|
||||
@@ -44,7 +43,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
const loadTrip = useTripStore((s) => s.loadTrip)
|
||||
const can = useCanDo()
|
||||
const canEditPlaces = can('place_edit', trip)
|
||||
const isNaverListImportEnabled = useAddonStore((s) => s.isEnabled('naver_list_import'))
|
||||
const isNaverListImportEnabled = true
|
||||
|
||||
const [fileImportOpen, setFileImportOpen] = useState(false)
|
||||
const [sidebarDropFile, setSidebarDropFile] = useState<File | null>(null)
|
||||
@@ -147,7 +146,11 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
|
||||
const filtered = useMemo(() => places.filter(p => {
|
||||
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
||||
if (categoryFilters.size > 0 && !categoryFilters.has(String(p.category_id))) return false
|
||||
if (categoryFilters.size > 0) {
|
||||
if (p.category_id == null) {
|
||||
if (!categoryFilters.has('uncategorized')) return false
|
||||
} else if (!categoryFilters.has(String(p.category_id))) return false
|
||||
}
|
||||
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
||||
return true
|
||||
@@ -257,7 +260,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
const label = categoryFilters.size === 0
|
||||
? t('places.allCategories')
|
||||
: categoryFilters.size === 1
|
||||
? categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories')
|
||||
? (categoryFilters.has('uncategorized') ? t('places.noCategory') : categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories'))
|
||||
: `${categoryFilters.size} ${t('places.categoriesSelected')}`
|
||||
return (
|
||||
<div style={{ marginTop: 6, position: 'relative' }}>
|
||||
@@ -300,6 +303,29 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{places.some(p => p.category_id == null) && (() => {
|
||||
const active = categoryFilters.has('uncategorized')
|
||||
return (
|
||||
<button onClick={() => toggleCategoryFilter('uncategorized')} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||
background: active ? 'var(--bg-hover)' : 'transparent',
|
||||
fontFamily: 'inherit', fontSize: 12, color: 'var(--text-muted)',
|
||||
textAlign: 'left', borderTop: '1px solid var(--border-faint)', marginTop: 2,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||
border: active ? 'none' : '1.5px solid var(--border-primary)',
|
||||
background: active ? 'var(--text-faint)' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{active && <Check size={10} strokeWidth={3} color="white" />}
|
||||
</div>
|
||||
<MapPin size={12} strokeWidth={2} color="var(--text-faint)" />
|
||||
<span style={{ flex: 1 }}>{t('places.noCategory')}</span>
|
||||
</button>
|
||||
)
|
||||
})()}
|
||||
{categoryFilters.size > 0 && (
|
||||
<button onClick={() => { setCategoryFiltersLocal(new Set()); onCategoryFilterChange?.(new Set()) }} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||
|
||||
@@ -134,7 +134,8 @@ describe('ReservationModal', () => {
|
||||
it('FE-PLANNER-RESMODAL-008: hotel type shows check-in/check-out time fields', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Accommodation/i }));
|
||||
expect(screen.getByText(/Check-in/i)).toBeInTheDocument();
|
||||
const checkInLabels = screen.getAllByText(/Check-in/i);
|
||||
expect(checkInLabels.length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText(/Check-out/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
|
||||
meta_departure_timezone: '', meta_arrival_timezone: '',
|
||||
meta_train_number: '', meta_platform: '', meta_seat: '',
|
||||
meta_check_in_time: '', meta_check_out_time: '',
|
||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
|
||||
})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
@@ -140,6 +140,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
meta_platform: meta.platform || '',
|
||||
meta_seat: meta.seat || '',
|
||||
meta_check_in_time: meta.check_in_time || '',
|
||||
meta_check_in_end_time: meta.check_in_end_time || '',
|
||||
meta_check_out_time: meta.check_out_time || '',
|
||||
hotel_place_id: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.place_id || '' })(),
|
||||
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
|
||||
@@ -156,7 +157,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
|
||||
meta_departure_timezone: '', meta_arrival_timezone: '',
|
||||
meta_train_number: '', meta_platform: '', meta_seat: '',
|
||||
meta_check_in_time: '', meta_check_out_time: '',
|
||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||
})
|
||||
setPendingFiles([])
|
||||
}
|
||||
@@ -207,6 +208,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
if (form.meta_arrival_timezone) metadata.arrival_timezone = form.meta_arrival_timezone
|
||||
} else if (form.type === 'hotel') {
|
||||
if (form.meta_check_in_time) metadata.check_in_time = form.meta_check_in_time
|
||||
if (form.meta_check_in_end_time) metadata.check_in_end_time = form.meta_check_in_end_time
|
||||
if (form.meta_check_out_time) metadata.check_out_time = form.meta_check_out_time
|
||||
} else if (form.type === 'train') {
|
||||
if (form.meta_train_number) metadata.train_number = form.meta_train_number
|
||||
@@ -245,6 +247,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
start_day_id: form.hotel_start_day,
|
||||
end_day_id: form.hotel_end_day,
|
||||
check_in: form.meta_check_in_time || null,
|
||||
check_in_end: form.meta_check_in_end_time || null,
|
||||
check_out: form.meta_check_out_time || null,
|
||||
confirmation: form.confirmation_number || null,
|
||||
}
|
||||
@@ -526,11 +529,15 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</div>
|
||||
</div>
|
||||
{/* Check-in/out times + Status */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.checkIn')}</label>
|
||||
<CustomTimePicker value={form.meta_check_in_time} onChange={v => set('meta_check_in_time', v)} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.checkInUntil')}</label>
|
||||
<CustomTimePicker value={form.meta_check_in_end_time} onChange={v => set('meta_check_in_end_time', v)} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.checkOut')}</label>
|
||||
<CustomTimePicker value={form.meta_check_out_time} onChange={v => set('meta_check_out_time', v)} />
|
||||
|
||||
@@ -91,12 +91,12 @@ describe('ReservationsPanel', () => {
|
||||
expect(els.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-RES-010: shows summary text with confirmed and pending counts', () => {
|
||||
const r1 = buildReservation({ title: 'Flight', type: 'flight', status: 'confirmed' });
|
||||
const r2 = buildReservation({ title: 'Hotel', type: 'hotel', status: 'pending' });
|
||||
it('FE-COMP-RES-010: shows reservations title and cards', () => {
|
||||
const r1 = buildReservation({ title: 'My Flight Booking', type: 'flight', status: 'confirmed' });
|
||||
const r2 = buildReservation({ title: 'Grand Hotel', type: 'hotel', status: 'pending' });
|
||||
render(<ReservationsPanel {...defaultProps} reservations={[r1, r2]} />);
|
||||
// reservations.summary = "{confirmed} confirmed, {pending} pending"
|
||||
expect(screen.getByText(/1 confirmed, 1 pending/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('My Flight Booking')).toBeInTheDocument();
|
||||
expect(screen.getByText('Grand Hotel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-RES-011: hotel reservation renders', () => {
|
||||
@@ -288,27 +288,14 @@ describe('ReservationsPanel', () => {
|
||||
|
||||
// ── Status toggle (canEdit=true) ────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-RESP-030: status label is a button when canEdit=true', () => {
|
||||
// Default: permissions empty → canEdit=true
|
||||
it('FE-PLANNER-RESP-030: status label is always a span (not clickable)', () => {
|
||||
const res = buildReservation({ title: 'My Booking', status: 'pending' });
|
||||
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
|
||||
// Status badge in card header is a button
|
||||
const pendingEls = screen.getAllByText('Pending');
|
||||
const statusSpan = pendingEls.find(el => el.tagName === 'SPAN');
|
||||
expect(statusSpan).toBeDefined();
|
||||
const statusBtn = pendingEls.find(el => el.tagName === 'BUTTON');
|
||||
expect(statusBtn).toBeDefined();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESP-031: clicking status button calls toggleReservationStatus', async () => {
|
||||
const user = userEvent.setup();
|
||||
const toggleReservationStatus = vi.fn().mockResolvedValue(undefined);
|
||||
// Seed the store with a mock toggleReservationStatus function
|
||||
useTripStore.setState({ toggleReservationStatus } as any);
|
||||
const res = buildReservation({ id: 42, title: 'Toggle Me', status: 'pending' });
|
||||
render(<ReservationsPanel {...defaultProps} tripId={1} reservations={[res]} />);
|
||||
const pendingEls = screen.getAllByText('Pending');
|
||||
const statusBtn = pendingEls.find(el => el.tagName === 'BUTTON');
|
||||
await user.click(statusBtn!);
|
||||
await waitFor(() => expect(toggleReservationStatus).toHaveBeenCalledWith(1, 42));
|
||||
expect(statusBtn).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Status (canEdit=false) ──────────────────────────────────────────────────
|
||||
|
||||
@@ -50,6 +50,16 @@ function buildAssignmentLookup(days, assignments) {
|
||||
return map
|
||||
}
|
||||
|
||||
/* ── Shared field label style ── */
|
||||
const fieldLabelStyle: React.CSSProperties = {
|
||||
fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||
color: 'var(--text-faint)', marginBottom: 5,
|
||||
}
|
||||
const fieldValueStyle: React.CSSProperties = {
|
||||
fontSize: 13, fontWeight: 500, color: 'var(--text-primary)',
|
||||
padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 10,
|
||||
}
|
||||
|
||||
interface ReservationCardProps {
|
||||
r: Reservation
|
||||
tripId: number
|
||||
@@ -84,184 +94,214 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) }
|
||||
}
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const fmtDate = (str) => {
|
||||
const dateOnly = str.includes('T') ? str.split('T')[0] : str
|
||||
return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
const fmtTime = (str) => {
|
||||
const d = new Date(str)
|
||||
return d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
||||
}
|
||||
|
||||
const hasDate = !!r.reservation_time
|
||||
const hasTime = r.reservation_time?.includes('T')
|
||||
const hasCode = !!r.confirmation_number
|
||||
const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length
|
||||
|
||||
return (
|
||||
<div style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}` }}>
|
||||
{/* Header bar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)' }}>
|
||||
<div style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
|
||||
{canEdit ? (
|
||||
<button onClick={handleToggle} style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}>
|
||||
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', padding: 0 }}>
|
||||
<div style={{
|
||||
borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
border: `1px solid ${confirmed ? 'rgba(22,163,74,0.25)' : 'rgba(217,119,6,0.25)'}`,
|
||||
background: 'var(--bg-card)',
|
||||
transition: 'box-shadow 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'}
|
||||
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
|
||||
padding: '12px 14px',
|
||||
background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)',
|
||||
}}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
fontSize: 12, fontWeight: 600, color: confirmed ? '#16a34a' : '#d97706',
|
||||
}}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
|
||||
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
|
||||
</span>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||
fontSize: 12, color: 'var(--text-muted)',
|
||||
padding: '3px 8px', borderRadius: 6,
|
||||
background: 'var(--bg-secondary)',
|
||||
}}>
|
||||
<TypeIcon size={12} style={{ color: typeInfo.color }} />
|
||||
{t(typeInfo.labelKey)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<span style={{
|
||||
fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginRight: 6,
|
||||
maxWidth: 140, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>{r.title}</span>
|
||||
{canEdit && (
|
||||
<button onClick={() => onEdit(r)} title={t('common.edit')} style={{
|
||||
appearance: 'none', border: 'none', background: 'transparent',
|
||||
width: 26, height: 26, borderRadius: 6, display: 'grid', placeItems: 'center',
|
||||
cursor: 'pointer', color: 'var(--text-faint)', flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(0,0,0,0.05)'; e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button onClick={() => setShowDeleteConfirm(true)} title={t('common.delete')} style={{
|
||||
appearance: 'none', border: 'none', background: 'transparent',
|
||||
width: 26, height: 26, borderRadius: 6, display: 'grid', placeItems: 'center',
|
||||
cursor: 'pointer', color: 'var(--text-faint)', flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(239,68,68,0.08)'; e.currentTarget.style.color = '#ef4444' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 12, flex: 1 }}>
|
||||
{/* Date / Time row */}
|
||||
{hasDate && (
|
||||
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasTime ? '1fr 1fr' : '1fr' }}>
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||
{fmtDate(r.reservation_time)}
|
||||
{r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && (
|
||||
<> – {fmtDate(r.reservation_end_time)}</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasTime && (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.time')}</div>
|
||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time.includes('T') ? fmtTime(r.reservation_end_time) : fmtTime(r.reservation_time.split('T')[0] + 'T' + r.reservation_end_time)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ width: 1, height: 10, background: 'var(--border-faint)' }} />
|
||||
<TypeIcon size={11} style={{ color: typeInfo.color, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{t(typeInfo.labelKey)}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
||||
{canEdit && (
|
||||
<button onClick={() => onEdit(r)} title={t('common.edit')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
{/* Booking code */}
|
||||
{hasCode && (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.confirmationCode')}</div>
|
||||
<div
|
||||
onMouseEnter={() => blurCodes && setCodeRevealed(true)}
|
||||
onMouseLeave={() => blurCodes && setCodeRevealed(false)}
|
||||
onClick={() => blurCodes && setCodeRevealed(v => !v)}
|
||||
style={{
|
||||
...fieldValueStyle, textAlign: 'center',
|
||||
fontFamily: '"SF Mono", "JetBrains Mono", Menlo, monospace', fontSize: 12.5,
|
||||
filter: blurCodes && !codeRevealed ? 'blur(5px)' : 'none',
|
||||
cursor: blurCodes ? 'pointer' : 'default',
|
||||
transition: 'filter 0.2s',
|
||||
}}
|
||||
>
|
||||
{r.confirmation_number}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button onClick={() => setShowDeleteConfirm(true)} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
|
||||
{/* Type-specific metadata */}
|
||||
{(() => {
|
||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||
if (!meta || Object.keys(meta).length === 0) return null
|
||||
const cells: { label: string; value: string }[] = []
|
||||
if (meta.airline) cells.push({ label: t('reservations.meta.airline'), value: meta.airline })
|
||||
if (meta.flight_number) cells.push({ label: t('reservations.meta.flightNumber'), value: meta.flight_number })
|
||||
if (meta.departure_airport) cells.push({ label: t('reservations.meta.from'), value: meta.departure_airport })
|
||||
if (meta.arrival_airport) cells.push({ label: t('reservations.meta.to'), value: meta.arrival_airport })
|
||||
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
|
||||
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
||||
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
||||
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: fmtTime('2000-01-01T' + meta.check_in_time) + (meta.check_in_end_time ? ` – ${fmtTime('2000-01-01T' + meta.check_in_end_time)}` : '') })
|
||||
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: fmtTime('2000-01-01T' + meta.check_out_time) })
|
||||
if (cells.length === 0) return null
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: cells.length > 1 ? `repeat(${Math.min(cells.length, 3)}, 1fr)` : '1fr' }}>
|
||||
{cells.map((c, i) => (
|
||||
<div key={i}>
|
||||
<div style={fieldLabelStyle}>{c.label}</div>
|
||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>{c.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Location / Accommodation / Assignment */}
|
||||
{r.location && (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.locationAddress')}</div>
|
||||
<div style={{ ...fieldValueStyle, display: 'flex', alignItems: 'center', gap: 6, fontWeight: 400 }}>
|
||||
<MapPin size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{r.accommodation_name && (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.meta.linkAccommodation')}</div>
|
||||
<div style={{ ...fieldValueStyle, display: 'flex', alignItems: 'center', gap: 6, fontWeight: 400 }}>
|
||||
<Hotel size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.accommodation_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{linked && (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.linkAssignment')}</div>
|
||||
<div style={{ ...fieldValueStyle, display: 'flex', alignItems: 'center', gap: 6, fontWeight: 400 }}>
|
||||
<Link2 size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{linked.dayTitle || t('dayplan.dayN', { n: linked.dayNumber })} — {linked.placeName}
|
||||
{linked.startTime ? ` · ${linked.startTime}${linked.endTime ? ' – ' + linked.endTime : ''}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{r.notes && (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.notes')}</div>
|
||||
<div style={{ ...fieldValueStyle, fontWeight: 400, lineHeight: 1.5 }}>{r.notes}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files */}
|
||||
{attachedFiles.length > 0 && (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('files.title')}</div>
|
||||
<div style={{ ...fieldValueStyle, display: 'flex', flexDirection: 'column', gap: 4, padding: '6px 10px' }}>
|
||||
{attachedFiles.map(f => (
|
||||
<a key={f.id} href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ display: 'flex', alignItems: 'center', gap: 5, textDecoration: 'none', cursor: 'pointer' }}>
|
||||
<FileText size={11} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
{(r.reservation_time || r.confirmation_number || r.location || linked || r.metadata) && (
|
||||
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{/* Row 1: Date, Time, Code */}
|
||||
{(r.reservation_time || r.confirmation_number) && (
|
||||
<div style={{ display: 'flex', gap: 0, borderRadius: 8, overflow: 'hidden', background: 'var(--bg-secondary)', boxShadow: '0 1px 6px rgba(0,0,0,0.08)' }}>
|
||||
{r.reservation_time && (
|
||||
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.date')}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||
{fmtDate(r.reservation_time)}
|
||||
{r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && (
|
||||
<> – {fmtDate(r.reservation_end_time)}</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{r.reservation_time?.includes('T') && (
|
||||
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.time')}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time.includes('T') ? fmtTime(r.reservation_end_time) : fmtTime(r.reservation_time.split('T')[0] + 'T' + r.reservation_end_time)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{r.confirmation_number && (
|
||||
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.confirmationCode')}</div>
|
||||
<div
|
||||
onMouseEnter={() => blurCodes && setCodeRevealed(true)}
|
||||
onMouseLeave={() => blurCodes && setCodeRevealed(false)}
|
||||
onClick={() => blurCodes && setCodeRevealed(v => !v)}
|
||||
style={{
|
||||
fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1,
|
||||
filter: blurCodes && !codeRevealed ? 'blur(5px)' : 'none',
|
||||
cursor: blurCodes ? 'pointer' : 'default',
|
||||
transition: 'filter 0.2s',
|
||||
}}
|
||||
>
|
||||
{r.confirmation_number}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Row 1b: Type-specific metadata */}
|
||||
{(() => {
|
||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||
if (!meta || Object.keys(meta).length === 0) return null
|
||||
const cells: { label: string; value: string }[] = []
|
||||
if (meta.airline) cells.push({ label: t('reservations.meta.airline'), value: meta.airline })
|
||||
if (meta.flight_number) cells.push({ label: t('reservations.meta.flightNumber'), value: meta.flight_number })
|
||||
if (meta.departure_airport) cells.push({ label: t('reservations.meta.from'), value: meta.departure_airport })
|
||||
if (meta.arrival_airport) cells.push({ label: t('reservations.meta.to'), value: meta.arrival_airport })
|
||||
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
|
||||
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
||||
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
||||
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: fmtTime('2000-01-01T' + meta.check_in_time) })
|
||||
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: fmtTime('2000-01-01T' + meta.check_out_time) })
|
||||
if (cells.length === 0) return null
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 0, borderRadius: 8, overflow: 'hidden', background: 'var(--bg-secondary)', boxShadow: '0 1px 6px rgba(0,0,0,0.08)' }}>
|
||||
{cells.map((c, i) => (
|
||||
<div key={i} style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: i < cells.length - 1 ? '1px solid var(--border-faint)' : 'none' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{c.label}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{c.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{/* Row 2: Location + Assignment */}
|
||||
{(r.location || linked || r.accommodation_name) && (
|
||||
<div className={`grid grid-cols-1 ${r.location && linked ? 'sm:grid-cols-2' : ''} gap-2`} style={{ paddingTop: 6, borderTop: '1px solid var(--border-faint)' }}>
|
||||
{r.location && (
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.locationAddress')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
<MapPin size={10} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{r.accommodation_name && (
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.meta.linkAccommodation')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
<Hotel size={10} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.accommodation_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{linked && (
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.linkAssignment')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
<Link2 size={10} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{linked.dayTitle || t('dayplan.dayN', { n: linked.dayNumber })} — {linked.placeName}
|
||||
{linked.startTime ? ` · ${linked.startTime}${linked.endTime ? ' – ' + linked.endTime : ''}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{r.notes && (
|
||||
<div style={{ padding: '0 12px 8px' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
|
||||
<div style={{ padding: '5px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.5 }}>
|
||||
{r.notes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files */}
|
||||
{attachedFiles.length > 0 && (
|
||||
<div style={{ padding: '0 12px 8px' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('files.title')}</div>
|
||||
<div style={{ padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{attachedFiles.map(f => (
|
||||
<a key={f.id} href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ display: 'flex', alignItems: 'center', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
|
||||
<FileText size={9} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Delete confirmation popup */}
|
||||
{/* Delete confirmation */}
|
||||
{showDeleteConfirm && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
@@ -316,20 +356,25 @@ interface SectionProps {
|
||||
function Section({ title, count, children, defaultOpen = true, accent }: SectionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
return (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<button onClick={() => setOpen(o => !o)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', marginBottom: 8, fontFamily: 'inherit',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', marginBottom: 12, fontFamily: 'inherit',
|
||||
userSelect: 'none',
|
||||
}}>
|
||||
{open ? <ChevronDown size={14} style={{ color: 'var(--text-faint)' }} /> : <ChevronRight size={14} style={{ color: 'var(--text-faint)' }} />}
|
||||
<span style={{ fontWeight: 700, fontSize: 12, color: 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{title}</span>
|
||||
<span style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>{title}</span>
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 700, padding: '1px 7px', borderRadius: 99,
|
||||
background: accent === 'green' ? 'rgba(22,163,74,0.1)' : 'var(--bg-tertiary)',
|
||||
color: accent === 'green' ? '#16a34a' : 'var(--text-faint)',
|
||||
fontSize: 11, fontWeight: 600, padding: '2px 7px', borderRadius: 99,
|
||||
background: 'var(--bg-tertiary)', color: 'var(--text-faint)',
|
||||
minWidth: 20, textAlign: 'center',
|
||||
}}>{count}</span>
|
||||
</button>
|
||||
{open && <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{children}</div>}
|
||||
{open && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(max(33.33% - 14px, 340px), 1fr))', gap: 14, alignItems: 'stretch' }}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -353,55 +398,152 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
||||
const canEdit = can('reservation_edit', trip)
|
||||
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
|
||||
|
||||
const storageKey = `trek-reservation-filters-${tripId}`
|
||||
const [typeFilters, setTypeFilters] = useState<Set<string>>(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(storageKey)
|
||||
return saved ? new Set(JSON.parse(saved)) : new Set()
|
||||
} catch { return new Set() }
|
||||
})
|
||||
|
||||
const toggleTypeFilter = (type: string) => {
|
||||
setTypeFilters(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(type)) next.delete(type); else next.add(type)
|
||||
sessionStorage.setItem(storageKey, JSON.stringify([...next]))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments])
|
||||
|
||||
const allPending = reservations.filter(r => r.status !== 'confirmed')
|
||||
const allConfirmed = reservations.filter(r => r.status === 'confirmed')
|
||||
const total = reservations.length
|
||||
const filtered = useMemo(() =>
|
||||
typeFilters.size === 0 ? reservations : reservations.filter(r => typeFilters.has(r.type)),
|
||||
[reservations, typeFilters])
|
||||
|
||||
const allPending = filtered.filter(r => r.status !== 'confirmed')
|
||||
const allConfirmed = filtered.filter(r => r.status === 'confirmed')
|
||||
const total = filtered.length
|
||||
|
||||
const usedTypes = useMemo(() => new Set(reservations.map(r => r.type)), [reservations])
|
||||
const typeCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const r of reservations) counts[r.type] = (counts[r.type] || 0) + 1
|
||||
return counts
|
||||
}, [reservations])
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
{/* Header */}
|
||||
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid var(--border-faint)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('reservations.title')}</h2>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'var(--text-faint)' }}>
|
||||
{total === 0 ? t('reservations.empty') : t('reservations.summary', { confirmed: allConfirmed.length, pending: allPending.length })}
|
||||
</p>
|
||||
{/* Unified toolbar */}
|
||||
<div style={{ padding: '24px 28px 0' }} className="max-md:!px-4 max-md:!pt-4">
|
||||
<div style={{
|
||||
background: 'var(--bg-tertiary)', borderRadius: 18,
|
||||
padding: '14px 16px 14px 22px',
|
||||
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||
{t('reservations.title')}
|
||||
</h2>
|
||||
|
||||
{reservations.length > 0 && (
|
||||
<>
|
||||
<div className="hidden md:block" style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
|
||||
<div className="hidden md:inline-flex" style={{ gap: 4, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
|
||||
<button
|
||||
onClick={() => { setTypeFilters(new Set()); sessionStorage.removeItem(storageKey) }}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 12px', borderRadius: 99, fontSize: 13, whiteSpace: 'nowrap',
|
||||
background: typeFilters.size === 0 ? 'var(--bg-card)' : 'transparent',
|
||||
color: typeFilters.size === 0 ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
fontWeight: typeFilters.size === 0 ? 500 : 400,
|
||||
boxShadow: typeFilters.size === 0 ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{t('common.all')}
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600,
|
||||
background: typeFilters.size === 0 ? 'var(--bg-tertiary)' : 'rgba(0,0,0,0.06)',
|
||||
color: 'var(--text-faint)',
|
||||
padding: '1px 6px', borderRadius: 99, minWidth: 16, textAlign: 'center',
|
||||
}}>{reservations.length}</span>
|
||||
</button>
|
||||
{TYPE_OPTIONS.filter(opt => usedTypes.has(opt.value)).map(opt => {
|
||||
const active = typeFilters.has(opt.value)
|
||||
const Icon = opt.Icon
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => toggleTypeFilter(opt.value)}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 12px', borderRadius: 99, fontSize: 13, whiteSpace: 'nowrap',
|
||||
background: active ? 'var(--bg-card)' : 'transparent',
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
fontWeight: active ? 500 : 400,
|
||||
boxShadow: active ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<Icon size={13} style={{ color: active ? opt.color : 'var(--text-faint)' }} />
|
||||
{t(opt.labelKey)}
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600,
|
||||
background: active ? 'var(--bg-tertiary)' : 'rgba(0,0,0,0.06)',
|
||||
color: 'var(--text-faint)',
|
||||
padding: '1px 6px', borderRadius: 99, minWidth: 16, textAlign: 'center',
|
||||
}}>{typeCounts[opt.value] || 0}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<button onClick={onAdd} style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
||||
marginLeft: 'auto',
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.5} />
|
||||
<span className="hidden sm:inline">{t('reservations.addManual')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button onClick={onAdd} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 99,
|
||||
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px' }}>
|
||||
{total === 0 ? (
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 28px 80px' }} className="max-md:!px-4 max-md:!pt-4">
|
||||
{total === 0 && reservations.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||
<BookMarked size={36} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
||||
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('reservations.empty')}</p>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0 }}>{t('reservations.emptyHint')}</p>
|
||||
</div>
|
||||
) : total === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-faint)' }}>{t('places.noneFound')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{allPending.length > 0 && (
|
||||
<Section title={t('reservations.pending')} count={allPending.length} accent="gray">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
|
||||
</div>
|
||||
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
|
||||
</Section>
|
||||
)}
|
||||
{allConfirmed.length > 0 && (
|
||||
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
|
||||
</div>
|
||||
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { act } from '@testing-library/react';
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useSystemNoticeStore } from '../../store/systemNoticeStore';
|
||||
import { BannerRenderer } from './SystemNoticeBanner';
|
||||
import type { SystemNoticeDTO } from '../../store/systemNoticeStore';
|
||||
|
||||
function makeBanner(overrides: Partial<SystemNoticeDTO> = {}): SystemNoticeDTO {
|
||||
return {
|
||||
id: 'banner-1',
|
||||
display: 'banner',
|
||||
severity: 'info',
|
||||
titleKey: 'Maintenance notice',
|
||||
bodyKey: 'System will be down briefly.',
|
||||
dismissible: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('BannerRenderer', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.post('/api/system-notices/:id/dismiss', () => {
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
);
|
||||
useSystemNoticeStore.setState({ notices: [], loaded: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
document.documentElement.style.removeProperty('--banner-stack-h');
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-001: renders banner with correct title and body', async () => {
|
||||
const notice = makeBanner();
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[notice]} />);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Maintenance notice')).toBeTruthy();
|
||||
expect(screen.getByText('System will be down briefly.')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-002: dismiss button calls store.dismiss(id)', async () => {
|
||||
const notice = makeBanner();
|
||||
useSystemNoticeStore.setState({ notices: [notice], loaded: true });
|
||||
|
||||
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[notice]} />);
|
||||
});
|
||||
|
||||
const dismissBtn = screen.getByLabelText(/Dismiss/);
|
||||
await act(async () => {
|
||||
fireEvent.click(dismissBtn);
|
||||
});
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalledWith('banner-1');
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-003: two banners stack correctly', async () => {
|
||||
const n1 = makeBanner({ id: 'banner-1', titleKey: 'First notice' });
|
||||
const n2 = makeBanner({ id: 'banner-2', titleKey: 'Second notice' });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[n1, n2]} />);
|
||||
});
|
||||
|
||||
expect(screen.getByText('First notice')).toBeTruthy();
|
||||
expect(screen.getByText('Second notice')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-004: third banner is not rendered (only top 2 shown)', async () => {
|
||||
// Server returns notices highest-priority first; BannerRenderer takes slice(0,2)
|
||||
const n1 = makeBanner({ id: 'banner-1', titleKey: 'Highest notice' });
|
||||
const n2 = makeBanner({ id: 'banner-2', titleKey: 'Second notice' });
|
||||
const n3 = makeBanner({ id: 'banner-3', titleKey: 'Lowest notice' });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[n1, n2, n3]} />);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Highest notice')).toBeTruthy();
|
||||
expect(screen.getByText('Second notice')).toBeTruthy();
|
||||
expect(screen.queryByText('Lowest notice')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-005: critical banner has aria-live="assertive"', async () => {
|
||||
const notice = makeBanner({ severity: 'critical', id: 'crit-1' });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[notice]} />);
|
||||
});
|
||||
|
||||
const alertEl = screen.getByRole('alert');
|
||||
expect(alertEl.getAttribute('aria-live')).toBe('assertive');
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-006: info banner has aria-live="polite"', async () => {
|
||||
const notice = makeBanner({ severity: 'info' });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[notice]} />);
|
||||
});
|
||||
|
||||
const statusEl = screen.getByRole('status');
|
||||
expect(statusEl.getAttribute('aria-live')).toBe('polite');
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-007: warn banner has aria-live="polite"', async () => {
|
||||
const notice = makeBanner({ severity: 'warn', id: 'warn-1' });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[notice]} />);
|
||||
});
|
||||
|
||||
const statusEl = screen.getByRole('status');
|
||||
expect(statusEl.getAttribute('aria-live')).toBe('polite');
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-008: renders nothing when notices array is empty', () => {
|
||||
const { container } = render(<BannerRenderer notices={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-009: non-dismissible banner hides dismiss button', async () => {
|
||||
const notice = makeBanner({ dismissible: false });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[notice]} />);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Maintenance notice')).toBeTruthy();
|
||||
expect(screen.queryByLabelText(/Dismiss/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Info, AlertTriangle, AlertOctagon, X } from 'lucide-react';
|
||||
import { useSystemNoticeStore } from '../../store/systemNoticeStore.js';
|
||||
import type { SystemNoticeDTO } from '../../store/systemNoticeStore.js';
|
||||
import { useTranslation } from '../../i18n/index.js';
|
||||
import { isRtlLanguage } from '../../i18n/index.js';
|
||||
import { runNoticeAction } from './noticeActions.js';
|
||||
|
||||
const SEVERITY_ICONS: Record<string, React.ElementType> = {
|
||||
info: Info,
|
||||
warn: AlertTriangle,
|
||||
critical: AlertOctagon,
|
||||
};
|
||||
|
||||
const SEVERITY = {
|
||||
info: {
|
||||
bg: 'bg-white dark:bg-slate-900',
|
||||
border: 'border-blue-500 dark:border-blue-400',
|
||||
text: 'text-slate-900 dark:text-slate-100',
|
||||
icon: 'text-blue-500 dark:text-blue-400',
|
||||
ariaLive: 'polite' as const,
|
||||
role: 'status' as const,
|
||||
},
|
||||
warn: {
|
||||
bg: 'bg-amber-50 dark:bg-amber-950',
|
||||
border: 'border-amber-500 dark:border-amber-400',
|
||||
text: 'text-amber-900 dark:text-amber-100',
|
||||
icon: 'text-amber-500 dark:text-amber-400',
|
||||
ariaLive: 'polite' as const,
|
||||
role: 'status' as const,
|
||||
},
|
||||
critical: {
|
||||
bg: 'bg-rose-50 dark:bg-rose-950',
|
||||
border: 'border-rose-600 dark:border-rose-400',
|
||||
text: 'text-rose-900 dark:text-rose-100',
|
||||
icon: 'text-rose-600 dark:text-rose-400',
|
||||
ariaLive: 'assertive' as const,
|
||||
role: 'alert' as const,
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface BannerItemProps {
|
||||
notice: SystemNoticeDTO;
|
||||
onDismiss: () => void;
|
||||
language: string;
|
||||
}
|
||||
|
||||
function CTALink({
|
||||
notice,
|
||||
label,
|
||||
onDismiss,
|
||||
}: {
|
||||
notice: SystemNoticeDTO;
|
||||
label: string;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
function handleClick() {
|
||||
if (!notice.cta) return;
|
||||
if (notice.cta.kind === 'nav') {
|
||||
navigate(notice.cta.href);
|
||||
if (notice.dismissible) onDismiss();
|
||||
} else {
|
||||
runNoticeAction(notice.cta.actionId, { navigate });
|
||||
const actionCta = notice.cta as { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean };
|
||||
if (actionCta.dismissOnAction !== false) onDismiss();
|
||||
}
|
||||
}
|
||||
|
||||
if (!notice.cta) return null;
|
||||
|
||||
if (notice.cta.kind === 'nav') {
|
||||
return (
|
||||
<a
|
||||
href={notice.cta.href}
|
||||
onClick={e => { e.preventDefault(); handleClick(); }}
|
||||
className="underline hover:no-underline font-medium ml-3 shrink-0"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="underline hover:no-underline font-medium ml-3 shrink-0"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function BannerItem({ notice, onDismiss, language }: BannerItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = SEVERITY[notice.severity] ?? SEVERITY.info;
|
||||
const title = t(notice.titleKey);
|
||||
const body = t(notice.bodyKey);
|
||||
const ctaLabel = notice.cta ? t(notice.cta.labelKey) : null;
|
||||
|
||||
// Tailwind 3.3+ supports border-s-4 (logical, RTL-aware)
|
||||
const accentBorder = 'border-s-4';
|
||||
|
||||
return (
|
||||
<div
|
||||
role={s.role}
|
||||
aria-live={s.ariaLive}
|
||||
aria-atomic="true"
|
||||
className={`flex items-start gap-x-3 py-3 px-4 ${accentBorder} ${s.bg} ${s.border} ${s.text}`}
|
||||
>
|
||||
{React.createElement(
|
||||
(SEVERITY_ICONS[notice.severity] ?? Info) as React.ElementType,
|
||||
{ size: 20, className: `shrink-0 mt-0.5 ${s.icon}` },
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-semibold">{title}</span>
|
||||
{body !== title && (
|
||||
<span className="ml-2 opacity-80">{body}</span>
|
||||
)}
|
||||
{ctaLabel && notice.cta && (
|
||||
<CTALink notice={notice} label={ctaLabel} onDismiss={onDismiss} />
|
||||
)}
|
||||
</div>
|
||||
{notice.dismissible && (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="shrink-0 p-2 -mr-2 rounded hover:bg-black/5 dark:hover:bg-white/10 transition"
|
||||
aria-label={`Dismiss: ${title}`}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AnimatedBannerItemProps {
|
||||
notice: SystemNoticeDTO;
|
||||
onDismiss: () => void;
|
||||
language: string;
|
||||
}
|
||||
|
||||
function AnimatedBannerItem({ notice, onDismiss, language }: AnimatedBannerItemProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const prefersReducedMotion =
|
||||
typeof window !== 'undefined' &&
|
||||
(window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof requestAnimationFrame !== 'undefined') {
|
||||
const id = requestAnimationFrame(() => setMounted(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const transition = prefersReducedMotion
|
||||
? 'transition-opacity duration-[120ms]'
|
||||
: 'transition-all duration-200 ease-out';
|
||||
const state = mounted ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-2';
|
||||
|
||||
return (
|
||||
<div className={`${transition} ${state}`}>
|
||||
<BannerItem notice={notice} onDismiss={onDismiss} language={language} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BannerRendererProps {
|
||||
notices: SystemNoticeDTO[];
|
||||
}
|
||||
|
||||
export function BannerRenderer({ notices }: BannerRendererProps) {
|
||||
const { dismiss } = useSystemNoticeStore();
|
||||
const { language } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Show at most 2 highest-priority banners
|
||||
const visible = notices.slice(0, 2);
|
||||
|
||||
// Report banner stack height for layout reflow
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
document.documentElement.style.setProperty('--banner-stack-h', el.offsetHeight + 'px');
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
document.documentElement.style.setProperty('--banner-stack-h', '0px');
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (visible.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed left-0 right-0 z-40"
|
||||
style={{ top: 'var(--nav-h, 0px)' }}
|
||||
>
|
||||
{visible.map((notice, i) => (
|
||||
<React.Fragment key={notice.id}>
|
||||
{i > 0 && <div className="border-t border-black/10 dark:border-white/10" />}
|
||||
<AnimatedBannerItem
|
||||
notice={notice}
|
||||
onDismiss={() => dismiss(notice.id)}
|
||||
language={language}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToastRendererProps {
|
||||
notices: SystemNoticeDTO[];
|
||||
}
|
||||
|
||||
export function ToastRenderer({ notices }: ToastRendererProps) {
|
||||
const { dismiss } = useSystemNoticeStore();
|
||||
const { t } = useTranslation();
|
||||
const firedRef = useRef(new Set<string>());
|
||||
|
||||
useEffect(() => {
|
||||
for (const notice of notices) {
|
||||
if (firedRef.current.has(notice.id)) continue;
|
||||
firedRef.current.add(notice.id);
|
||||
|
||||
// Critical should not be a toast — log and skip
|
||||
if (notice.severity === 'critical') {
|
||||
console.warn(
|
||||
`[systemNotices] notice "${notice.id}" is critical but display=toast. ` +
|
||||
'Should be banner or modal.'
|
||||
);
|
||||
dismiss(notice.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const variantMap: Record<string, string> = { info: 'info', warn: 'warning' };
|
||||
const variant = variantMap[notice.severity] ?? 'info';
|
||||
const titleStr = t(notice.titleKey);
|
||||
const bodyStr = t(notice.bodyKey);
|
||||
const message = bodyStr !== titleStr ? `${titleStr}: ${bodyStr}` : titleStr;
|
||||
const duration = notice.severity === 'warn' ? 9000 : 6000;
|
||||
|
||||
// Fire the toast, retrying on the next frame if __addToast isn't registered yet
|
||||
// (race between ToastContainer mounting and SystemNoticeHost mounting on cold load).
|
||||
const fireToast = (attempt = 0) => {
|
||||
if (typeof window.__addToast === 'function') {
|
||||
window.__addToast(message, variant as 'info' | 'success' | 'error' | 'warning', duration);
|
||||
} else if (attempt < 10) {
|
||||
requestAnimationFrame(() => fireToast(attempt + 1));
|
||||
return; // don't schedule dismiss until the toast actually fires
|
||||
} else {
|
||||
console.warn(`[systemNotices] toast "${notice.id}" dropped — __addToast never registered`);
|
||||
}
|
||||
setTimeout(() => dismiss(notice.id), duration + 500);
|
||||
};
|
||||
fireToast();
|
||||
}
|
||||
}, [notices]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useSystemNoticeStore } from '../../store/systemNoticeStore.js';
|
||||
import { ModalRenderer } from './SystemNoticeModal.js';
|
||||
import { BannerRenderer, ToastRenderer } from './SystemNoticeBanner.js';
|
||||
|
||||
export function SystemNoticeHost() {
|
||||
const { notices, loaded } = useSystemNoticeStore();
|
||||
|
||||
// Notices are fetched by authStore after login (see App.tsx / authStore modification).
|
||||
// Cold-session fetch (page reload with valid session) is triggered here:
|
||||
useEffect(() => {
|
||||
// Only fetch if not already loaded (authStore may have already triggered)
|
||||
if (!loaded) {
|
||||
useSystemNoticeStore.getState().fetch();
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!loaded) return null;
|
||||
|
||||
const modals = notices.filter(n => n.display === 'modal');
|
||||
const banners = notices.filter(n => n.display === 'banner');
|
||||
const toasts = notices.filter(n => n.display === 'toast');
|
||||
|
||||
return (
|
||||
<>
|
||||
<BannerRenderer notices={banners} />
|
||||
<ModalRenderer notices={modals} />
|
||||
<ToastRenderer notices={toasts} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { act } from '@testing-library/react';
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useSystemNoticeStore } from '../../store/systemNoticeStore';
|
||||
import { ModalRenderer } from './SystemNoticeModal';
|
||||
import type { SystemNoticeDTO } from '../../store/systemNoticeStore';
|
||||
|
||||
// Stub react-markdown to avoid async chunk issues in tests
|
||||
vi.mock('react-markdown', () => ({
|
||||
default: ({ children }: { children: string }) => <span data-testid="md">{children}</span>,
|
||||
}));
|
||||
vi.mock('remark-gfm', () => ({ default: () => ({}) }));
|
||||
vi.mock('rehype-sanitize', () => ({ default: () => ({}) }));
|
||||
|
||||
function makeNotice(overrides: Partial<SystemNoticeDTO> = {}): SystemNoticeDTO {
|
||||
return {
|
||||
id: 'test-notice-1',
|
||||
display: 'modal',
|
||||
severity: 'info',
|
||||
titleKey: 'Test Title',
|
||||
bodyKey: 'Test body text',
|
||||
dismissible: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance fake timers past the grace delay (2× rAF fallback → each is a
|
||||
* setTimeout(0), then 500ms). All three timers fire in sequence with
|
||||
* runAllTimers() — no need to advance exact milliseconds.
|
||||
*/
|
||||
async function flushGraceDelay() {
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
}
|
||||
|
||||
describe('ModalRenderer', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.post('/api/system-notices/:id/dismiss', () => {
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
);
|
||||
useSystemNoticeStore.setState({ notices: [], loaded: true });
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-001: renders title and body after grace delay', async () => {
|
||||
const notice = makeNotice();
|
||||
render(<ModalRenderer notices={[notice]} />);
|
||||
|
||||
// Before delay fires: dialog present but body not yet visible (class-based)
|
||||
expect(screen.getByRole('dialog')).toBeTruthy();
|
||||
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Test Title')).toBeTruthy();
|
||||
expect(screen.getByText('Test body text')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-002: dismiss button calls store.dismiss(id)', async () => {
|
||||
const notice = makeNotice();
|
||||
useSystemNoticeStore.setState({ notices: [notice], loaded: true });
|
||||
|
||||
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
|
||||
render(<ModalRenderer notices={[notice]} />);
|
||||
|
||||
await flushGraceDelay();
|
||||
|
||||
const dismissBtn = screen.getByLabelText('Dismiss');
|
||||
await act(async () => {
|
||||
fireEvent.click(dismissBtn);
|
||||
});
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalledWith('test-notice-1');
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-003: non-dismissible critical notice hides dismiss affordance', async () => {
|
||||
const notice = makeNotice({ severity: 'critical', dismissible: false });
|
||||
render(<ModalRenderer notices={[notice]} />);
|
||||
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.queryByLabelText('Dismiss')).toBeNull();
|
||||
expect(screen.queryByText('Not now')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-004: ESC key does not close non-dismissible notice', async () => {
|
||||
const notice = makeNotice({ severity: 'critical', dismissible: false });
|
||||
useSystemNoticeStore.setState({ notices: [notice], loaded: true });
|
||||
|
||||
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
|
||||
render(<ModalRenderer notices={[notice]} />);
|
||||
|
||||
await flushGraceDelay();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
});
|
||||
|
||||
expect(dismissSpy).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('dialog')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-005: CTA nav button dismisses all notices (not just current)', async () => {
|
||||
// CTA is only shown on the last page; navigate there first
|
||||
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
|
||||
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B', cta: { kind: 'nav', labelKey: 'Go to trips', href: '/trips' } });
|
||||
useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true });
|
||||
|
||||
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
|
||||
render(<ModalRenderer notices={[noticeA, noticeB]} />);
|
||||
|
||||
await flushGraceDelay();
|
||||
|
||||
// Navigate to last page
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Go to notice 2'));
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
const ctaBtn = screen.getByRole('button', { name: 'Go to trips' });
|
||||
await act(async () => {
|
||||
fireEvent.click(ctaBtn);
|
||||
});
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalledWith('n-a');
|
||||
expect(dismissSpy).toHaveBeenCalledWith('n-b');
|
||||
expect(dismissSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-006: modal backdrop has opacity-0 class before grace delay fires', () => {
|
||||
const notice = makeNotice();
|
||||
const { container } = render(<ModalRenderer notices={[notice]} />);
|
||||
|
||||
// Dialog is in DOM, backdrop has opacity-0 before timers fire
|
||||
expect(screen.getByRole('dialog')).toBeTruthy();
|
||||
const backdrop = container.querySelector('[role="presentation"]');
|
||||
expect(backdrop?.className).toContain('opacity-0');
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-007: body params are interpolated before rendering', async () => {
|
||||
const notice = makeNotice({
|
||||
bodyKey: 'Hello {name}, welcome to {app}',
|
||||
bodyParams: { name: 'Alice', app: 'TREK' },
|
||||
});
|
||||
render(<ModalRenderer notices={[notice]} />);
|
||||
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Hello Alice, welcome to TREK')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-008: empty notices renders nothing', () => {
|
||||
const { container } = render(<ModalRenderer notices={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
// ── Multipage (pager) ──────────────────────────────────────────────────────
|
||||
|
||||
it('FE-SN-MODAL-009: pager is hidden when only one notice is present', async () => {
|
||||
const notice = makeNotice();
|
||||
render(<ModalRenderer notices={[notice]} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.queryByLabelText('Previous notice')).toBeNull();
|
||||
expect(screen.queryByLabelText('Next notice')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-010: pager shows counter and dots for multiple notices', async () => {
|
||||
const notices = [
|
||||
makeNotice({ id: 'n1', titleKey: 'Notice A' }),
|
||||
makeNotice({ id: 'n2', titleKey: 'Notice B' }),
|
||||
makeNotice({ id: 'n3', titleKey: 'Notice C' }),
|
||||
];
|
||||
render(<ModalRenderer notices={notices} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('1 / 3')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Go to notice 1')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Go to notice 2')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Go to notice 3')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Previous notice')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Next notice')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-011: next button advances to the next notice; prev returns', async () => {
|
||||
const notices = [
|
||||
makeNotice({ id: 'n1', titleKey: 'Notice A' }),
|
||||
makeNotice({ id: 'n2', titleKey: 'Notice B' }),
|
||||
makeNotice({ id: 'n3', titleKey: 'Notice C' }),
|
||||
];
|
||||
render(<ModalRenderer notices={notices} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('1 / 3')).toBeTruthy();
|
||||
expect(screen.getByText('Notice A')).toBeTruthy();
|
||||
|
||||
// Navigate to page 2
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Next notice'));
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('2 / 3')).toBeTruthy();
|
||||
expect(screen.getByText('Notice B')).toBeTruthy();
|
||||
|
||||
// Navigate back to page 1
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Previous notice'));
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('1 / 3')).toBeTruthy();
|
||||
expect(screen.getByText('Notice A')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-012: ArrowRight / ArrowLeft keys navigate between pages', async () => {
|
||||
const notices = [
|
||||
makeNotice({ id: 'n1', titleKey: 'Notice A' }),
|
||||
makeNotice({ id: 'n2', titleKey: 'Notice B' }),
|
||||
];
|
||||
render(<ModalRenderer notices={notices} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Notice A')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: 'ArrowRight' });
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Notice B')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: 'ArrowLeft' });
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Notice A')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-013: clicking a dot navigates directly to that page', async () => {
|
||||
const notices = [
|
||||
makeNotice({ id: 'n1', titleKey: 'Notice A' }),
|
||||
makeNotice({ id: 'n2', titleKey: 'Notice B' }),
|
||||
makeNotice({ id: 'n3', titleKey: 'Notice C' }),
|
||||
];
|
||||
render(<ModalRenderer notices={notices} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Notice A')).toBeTruthy();
|
||||
|
||||
// Click third dot
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Go to notice 3'));
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('3 / 3')).toBeTruthy();
|
||||
expect(screen.getByText('Notice C')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-014: non-dismissible notice locks the pager (prev/next/dots disabled)', async () => {
|
||||
const notices = [
|
||||
makeNotice({ id: 'n1', titleKey: 'Notice A', dismissible: false }),
|
||||
makeNotice({ id: 'n2', titleKey: 'Notice B' }),
|
||||
];
|
||||
render(<ModalRenderer notices={notices} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
const prevBtn = screen.getByLabelText('Previous notice') as HTMLButtonElement;
|
||||
const nextBtn = screen.getByLabelText('Next notice') as HTMLButtonElement;
|
||||
const dot2 = screen.getByLabelText('Go to notice 2') as HTMLButtonElement;
|
||||
|
||||
expect(prevBtn.disabled).toBe(true);
|
||||
expect(nextBtn.disabled).toBe(true);
|
||||
expect(dot2.disabled).toBe(true);
|
||||
|
||||
// Arrow keys should also be blocked
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: 'ArrowRight' });
|
||||
});
|
||||
// Still on page 1 (no grace delay needed because page didn't change)
|
||||
expect(screen.getByText('1 / 2')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-015: dismissing a notice does not skip the next one (regression)', async () => {
|
||||
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
|
||||
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' });
|
||||
const noticeC = makeNotice({ id: 'n-c', titleKey: 'Notice C' });
|
||||
|
||||
useSystemNoticeStore.setState({ notices: [noticeA, noticeB, noticeC], loaded: true });
|
||||
const { rerender } = render(<ModalRenderer notices={[noticeA, noticeB, noticeC]} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Notice A')).toBeTruthy();
|
||||
expect(screen.getByText('1 / 3')).toBeTruthy();
|
||||
|
||||
// Navigate to last page where X button is available
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Go to notice 3'));
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
// Dismiss all from last page — store shrinks
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Dismiss'));
|
||||
useSystemNoticeStore.setState({ notices: [], loaded: true });
|
||||
rerender(<ModalRenderer notices={[]} />);
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
// All dismissed — modal should be gone
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-017: X button dismisses all notices, not just the current one', async () => {
|
||||
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
|
||||
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' });
|
||||
useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true });
|
||||
|
||||
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
|
||||
render(<ModalRenderer notices={[noticeA, noticeB]} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
// X button only appears on the last page — navigate there
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Go to notice 2'));
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Dismiss'));
|
||||
});
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalledWith('n-a');
|
||||
expect(dismissSpy).toHaveBeenCalledWith('n-b');
|
||||
expect(dismissSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-018: ESC key dismisses all notices when on last page', async () => {
|
||||
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
|
||||
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' });
|
||||
useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true });
|
||||
|
||||
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
|
||||
render(<ModalRenderer notices={[noticeA, noticeB]} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
// ESC only works on last page — navigate there first
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Go to notice 2'));
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
});
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalledWith('n-a');
|
||||
expect(dismissSpy).toHaveBeenCalledWith('n-b');
|
||||
expect(dismissSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-016: dismissing the only remaining notice closes the modal', async () => {
|
||||
const notice = makeNotice({ id: 'solo', titleKey: 'Solo Notice' });
|
||||
useSystemNoticeStore.setState({ notices: [notice], loaded: true });
|
||||
|
||||
const { rerender, container } = render(<ModalRenderer notices={[notice]} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Solo Notice')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Dismiss'));
|
||||
useSystemNoticeStore.setState({ notices: [], loaded: true });
|
||||
rerender(<ModalRenderer notices={[]} />);
|
||||
});
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,819 @@
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import { useSystemNoticeStore } from '../../store/systemNoticeStore.js';
|
||||
import type { SystemNoticeDTO } from '../../store/systemNoticeStore.js';
|
||||
import { useTranslation, isRtlLanguage } from '../../i18n/index.js';
|
||||
import { runNoticeAction } from './noticeActions.js';
|
||||
|
||||
const ReactMarkdown = React.lazy(() =>
|
||||
import('react-markdown').then(m => ({ default: m.default }))
|
||||
);
|
||||
|
||||
/** Safe rAF shim — falls back to setTimeout(0) in environments without rAF (e.g. jsdom). */
|
||||
function scheduleFrame(cb: () => void): () => void {
|
||||
if (typeof requestAnimationFrame !== 'undefined') {
|
||||
const id = requestAnimationFrame(cb);
|
||||
return () => cancelAnimationFrame(id);
|
||||
}
|
||||
const id = setTimeout(cb, 0);
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
|
||||
const SEVERITY_ICONS: Record<string, React.ElementType> = {
|
||||
info: Info,
|
||||
warn: AlertTriangle,
|
||||
critical: AlertOctagon,
|
||||
};
|
||||
|
||||
const SEVERITY_ACCENT: Record<string, string> = {
|
||||
info: 'text-blue-500 dark:text-blue-400 bg-blue-50 dark:bg-blue-950',
|
||||
warn: 'text-amber-500 dark:text-amber-400 bg-amber-50 dark:bg-amber-950',
|
||||
critical: 'text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-950',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
notices: SystemNoticeDTO[];
|
||||
}
|
||||
|
||||
// Inner content shared between desktop and mobile layouts
|
||||
interface ContentProps {
|
||||
notice: SystemNoticeDTO;
|
||||
title: string;
|
||||
body: string;
|
||||
ctaLabel: string | null;
|
||||
titleId: string;
|
||||
bodyId: string;
|
||||
isDark: boolean;
|
||||
onDismiss: () => void;
|
||||
onDismissAll: () => void;
|
||||
onCTA: () => void;
|
||||
// Pager
|
||||
total: number;
|
||||
currentPage: number;
|
||||
canPage: boolean;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onGoto: (i: number) => void;
|
||||
}
|
||||
|
||||
function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark, onDismiss, onDismissAll, onCTA, total, currentPage, canPage, onPrev, onNext, onGoto }: ContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const isLastPage = total <= 1 || currentPage === total - 1;
|
||||
|
||||
const DefaultIcon = SEVERITY_ICONS[notice.severity] ?? Info;
|
||||
const LucideIcon: React.ElementType = notice.icon
|
||||
? ((LucideIcons as Record<string, unknown>)[notice.icon] as React.ElementType) ?? DefaultIcon
|
||||
: DefaultIcon;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col relative flex-1">
|
||||
{/* Dismiss X button — only on last page so users read all notices */}
|
||||
{notice.dismissible && isLastPage && (
|
||||
<button
|
||||
onClick={onDismissAll}
|
||||
className="absolute top-4 right-4 p-2 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Hero image (not inline) */}
|
||||
{notice.media && notice.media.placement !== 'inline' && (
|
||||
<div
|
||||
className="w-full overflow-hidden"
|
||||
style={{ aspectRatio: notice.media.aspectRatio ?? '16/9' }}
|
||||
>
|
||||
<img
|
||||
src={isDark && notice.media.srcDark ? notice.media.srcDark : notice.media.src}
|
||||
alt={t(notice.media.altKey)}
|
||||
className="w-full h-full object-cover"
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Special warm header for Heart icon (thank-you notice) */}
|
||||
{notice.icon === 'Heart' && !notice.media && (
|
||||
<div className="relative overflow-hidden bg-gradient-to-br from-rose-500 via-pink-500 to-indigo-500 px-8 py-5 text-center">
|
||||
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 20% 50%, white 1px, transparent 1px), radial-gradient(circle at 80% 20%, white 1px, transparent 1px), radial-gradient(circle at 60% 80%, white 1px, transparent 1px)', backgroundSize: '60px 60px, 80px 80px, 40px 40px' }} />
|
||||
<div className="relative flex items-center justify-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center ring-2 ring-white/10">
|
||||
<LucideIcon size={20} className="text-white" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h2 id={titleId} className="text-lg font-bold text-white leading-tight">{title}</h2>
|
||||
<p className="text-xs text-white/60 font-medium">TREK 3.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`${notice.icon === 'Heart' && !notice.media ? 'px-8 py-6' : 'p-8'} flex flex-col flex-1`}>
|
||||
{/* Severity icon (when no hero and not Heart) */}
|
||||
{!notice.media && notice.icon !== 'Heart' && (
|
||||
<div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 ${SEVERITY_ACCENT[notice.severity] ?? ''}`}>
|
||||
<LucideIcon size={28} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title (not for Heart — rendered in gradient header) */}
|
||||
{(notice.icon !== 'Heart' || notice.media) && (
|
||||
<h2
|
||||
id={titleId}
|
||||
className="text-xl font-semibold text-center text-slate-900 dark:text-slate-100 mb-3"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{/* Body — markdown (long body text uses left-aligned layout) */}
|
||||
<div
|
||||
id={bodyId}
|
||||
className="text-sm leading-relaxed text-slate-600 dark:text-slate-400 mx-auto mb-4 text-center"
|
||||
>
|
||||
<React.Suspense fallback={<p className="text-sm text-slate-500">{body}</p>}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSanitize]}
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-indigo-600 dark:text-indigo-400 underline decoration-indigo-300 dark:decoration-indigo-700 hover:decoration-indigo-500 dark:hover:decoration-indigo-400 underline-offset-2 transition-colors"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
p: ({ children }) => {
|
||||
// Signature line styling (e.g. "— Maurice")
|
||||
const text = typeof children === 'string' ? children : Array.isArray(children) ? children.find(c => typeof c === 'string') : '';
|
||||
if (typeof text === 'string' && text.trim().startsWith('—') && text.trim().length < 30) {
|
||||
return <p className="mt-4 mb-3 text-base font-semibold text-slate-800 dark:text-slate-200 italic">{children}</p>;
|
||||
}
|
||||
return <p className="mb-3 last:mb-0">{children}</p>;
|
||||
},
|
||||
hr: () => (
|
||||
<div className="my-5 flex items-center gap-3">
|
||||
<div className="flex-1 border-t border-slate-200 dark:border-slate-700" />
|
||||
<span className="text-slate-300 dark:text-slate-600 text-xs">♡</span>
|
||||
<div className="flex-1 border-t border-slate-200 dark:border-slate-700" />
|
||||
</div>
|
||||
),
|
||||
strong: ({ children }) => <strong className="font-semibold text-slate-800 dark:text-slate-200">{children}</strong>,
|
||||
ul: ({ children }) => <ul className="list-disc list-inside text-left">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal list-inside text-left">{children}</ol>,
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
</ReactMarkdown>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
|
||||
{/* Inline image */}
|
||||
{notice.media?.placement === 'inline' && (
|
||||
<div
|
||||
className="w-full overflow-hidden rounded-lg mb-4 mx-auto"
|
||||
style={{ aspectRatio: notice.media.aspectRatio ?? '16/9' }}
|
||||
>
|
||||
<img
|
||||
src={isDark && notice.media.srcDark ? notice.media.srcDark : notice.media.src}
|
||||
alt={t(notice.media.altKey)}
|
||||
className="w-full h-full object-cover"
|
||||
decoding="async"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Highlights */}
|
||||
{notice.highlights && notice.highlights.length > 0 && (
|
||||
<ul className="mx-auto mb-4 space-y-2">
|
||||
{notice.highlights.map((h, i) => {
|
||||
const HIcon: React.ElementType | null = h.iconName
|
||||
? ((LucideIcons as Record<string, unknown>)[h.iconName] as React.ElementType) ?? null
|
||||
: null;
|
||||
return (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
{HIcon
|
||||
? <HIcon size={16} className="text-blue-500 shrink-0" />
|
||||
: <span className="text-blue-500 shrink-0">✓</span>
|
||||
}
|
||||
{t(h.labelKey)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Pager — dots, arrows, counter (only when multiple notices) */}
|
||||
{total > 1 && (
|
||||
<div className="flex flex-col items-center gap-1 mt-6 mb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={!canPage || currentPage === 0}
|
||||
aria-label={t('system_notice.pager.prev')}
|
||||
className="p-1 rounded text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
|
||||
{Array.from({ length: total }, (_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => { if (canPage) onGoto(i); }}
|
||||
aria-label={t('system_notice.pager.goto').replace('{n}', String(i + 1))}
|
||||
aria-current={i === currentPage ? 'true' : undefined}
|
||||
disabled={!canPage && i !== currentPage}
|
||||
className={`w-2 h-2 rounded-full transition-colors ${
|
||||
i === currentPage
|
||||
? 'bg-blue-500 dark:bg-blue-400'
|
||||
: 'bg-slate-300 dark:bg-slate-600 hover:bg-slate-400 dark:hover:bg-slate-500 disabled:cursor-not-allowed'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={!canPage || currentPage === total - 1}
|
||||
aria-label={t('system_notice.pager.next')}
|
||||
className="p-1 rounded text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-slate-400 tabular-nums">
|
||||
{t('system_notice.pager.counter')
|
||||
.replace('{current}', String(currentPage + 1))
|
||||
.replace('{total}', String(total))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA + dismiss link */}
|
||||
<div className="flex flex-col items-center gap-3 mt-auto">
|
||||
{!isLastPage && total > 1 ? (
|
||||
/* Non-last page: "Next" button to advance through all notices */
|
||||
<button
|
||||
id={`notice-cta-${notice.id}`}
|
||||
onClick={onNext}
|
||||
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{t('system_notice.pager.next')} <ChevronRight size={16} />
|
||||
</button>
|
||||
) : ctaLabel ? (
|
||||
<button
|
||||
id={`notice-cta-${notice.id}`}
|
||||
onClick={onCTA}
|
||||
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors"
|
||||
>
|
||||
{ctaLabel}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
id={`notice-cta-${notice.id}`}
|
||||
onClick={onDismissAll}
|
||||
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors"
|
||||
>
|
||||
{t('common.ok')}
|
||||
</button>
|
||||
)}
|
||||
{notice.dismissible && isLastPage && ctaLabel && (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
|
||||
>
|
||||
Not now
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalRenderer({ notices }: Props) {
|
||||
const [idx, setIdx] = useState(0);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [pageAnnouncement, setPageAnnouncement] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const { dismiss } = useSystemNoticeStore();
|
||||
const { t, language } = useTranslation();
|
||||
|
||||
const [isMobile, setIsMobile] = useState(
|
||||
() => typeof window !== 'undefined' && (window.matchMedia?.('(max-width: 639px)')?.matches ?? false)
|
||||
);
|
||||
|
||||
const [isDark, setIsDark] = useState(
|
||||
() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||
);
|
||||
|
||||
const prefersReducedMotion =
|
||||
typeof window !== 'undefined' &&
|
||||
(window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false);
|
||||
|
||||
const notice = notices[idx] ?? null;
|
||||
|
||||
// Non-dismissible notices lock the pager so users must act before advancing.
|
||||
const canPage = notice?.dismissible !== false;
|
||||
|
||||
const touchStartX = useRef<number | null>(null);
|
||||
const touchStartY = useRef<number | null>(null);
|
||||
// 'h' once we classify the gesture as horizontal, 'v' for vertical, null = unclassified
|
||||
const dragLockRef = useRef<'h' | 'v' | null>(null);
|
||||
// Keep a ref to the current notice id so dismiss/CTA handlers see the latest value
|
||||
const noticeIdRef = useRef<string | null>(null);
|
||||
noticeIdRef.current = notice?.id ?? null;
|
||||
|
||||
// Page-slide animation refs.
|
||||
// isPageNavRef: set to true just before a user-initiated page change so the
|
||||
// grace-delay effect knows to run a slide instead of hide+show.
|
||||
// slideDirRef: 'right' = new content enters from the right (Next), 'left' = from the left (Prev).
|
||||
// contentWrapperRef: the div wrapping NoticeContent — we animate its transform directly.
|
||||
const isPageNavRef = useRef(false);
|
||||
const slideDirRef = useRef<'left' | 'right'>('right');
|
||||
// Mobile drag strip — wraps all 3 slots and is translated to reveal prev/current/next
|
||||
const stripRef = useRef<HTMLDivElement>(null);
|
||||
// The sheet element itself — animated on vertical drag-to-dismiss
|
||||
const sheetRef = useRef<HTMLDivElement>(null);
|
||||
// Clip container ref + cached max height — used to pin sheet height to tallest notice
|
||||
const clipRef = useRef<HTMLDivElement>(null);
|
||||
const maxClipHeightRef = useRef(0);
|
||||
const contentWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Mobile breakpoint
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia?.('(max-width: 639px)');
|
||||
if (!mq) return;
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
// Dark mode observer
|
||||
useEffect(() => {
|
||||
const obs = new MutationObserver(() => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
});
|
||||
obs.observe(document.documentElement, { attributeFilter: ['class'] });
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
// Clamp idx when notices array shrinks (e.g. after dismiss of the last page)
|
||||
useEffect(() => {
|
||||
if (notices.length > 0 && idx >= notices.length) {
|
||||
setIdx(notices.length - 1);
|
||||
}
|
||||
}, [notices.length, idx]);
|
||||
|
||||
// Fires on every notice-id change. Branches on whether this is a user-initiated
|
||||
// page navigation (slide the content wrapper) or a modal appear/dismiss-advance
|
||||
// (grace-delay the whole modal).
|
||||
useEffect(() => {
|
||||
if (!notice) return;
|
||||
|
||||
// ── Page navigation: slide new content in, keep modal visible ────────────
|
||||
if (isPageNavRef.current) {
|
||||
isPageNavRef.current = false;
|
||||
const el = contentWrapperRef.current;
|
||||
if (el && !prefersReducedMotion) {
|
||||
// The handler already set el.style.transform to the start position
|
||||
// synchronously before setIdx was called. Trigger the transition here.
|
||||
requestAnimationFrame(() => {
|
||||
el.style.transition = 'transform 260ms ease-out';
|
||||
el.style.transform = 'translateX(0)';
|
||||
const onEnd = () => {
|
||||
el.style.transition = '';
|
||||
el.style.transform = '';
|
||||
el.removeEventListener('transitionend', onEnd);
|
||||
};
|
||||
el.addEventListener('transitionend', onEnd);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Modal appearing / dismiss-advance: grace delay ────────────────────────
|
||||
setVisible(false);
|
||||
let cancelled = false;
|
||||
let timerId: ReturnType<typeof setTimeout> | undefined;
|
||||
const cancel1 = scheduleFrame(() => {
|
||||
const cancel2 = scheduleFrame(() => {
|
||||
timerId = setTimeout(() => {
|
||||
if (!cancelled) setVisible(true);
|
||||
}, 500);
|
||||
});
|
||||
if (cancelled) cancel2();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancel1();
|
||||
if (timerId !== undefined) clearTimeout(timerId);
|
||||
};
|
||||
}, [notice?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ESC key — closes all modal notices (only on last page so users read all notices)
|
||||
const isLastPage = notices.length <= 1 || idx === notices.length - 1;
|
||||
useEffect(() => {
|
||||
if (!visible || !notice?.dismissible || !isLastPage) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') handleDismissAll();
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [visible, notice?.dismissible, isLastPage]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Arrow-key pager navigation
|
||||
useEffect(() => {
|
||||
if (!visible || notices.length <= 1) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
||||
if (!canPage) return;
|
||||
// In RTL layouts the directional meaning of arrows is flipped
|
||||
const forward = isRtlLanguage(language) ? e.key === 'ArrowLeft' : e.key === 'ArrowRight';
|
||||
if (forward && idx < notices.length - 1) {
|
||||
triggerPageSlide('right');
|
||||
setIdx(idx + 1);
|
||||
announceIndex(idx + 1, notices.length);
|
||||
} else if (!forward && idx > 0) {
|
||||
triggerPageSlide('left');
|
||||
setIdx(idx - 1);
|
||||
announceIndex(idx - 1, notices.length);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [visible, idx, notices.length, canPage, language]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Body scroll lock
|
||||
useEffect(() => {
|
||||
if (visible && notice) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [visible, notice]);
|
||||
|
||||
// Pin the strip to the tallest notice height seen so far.
|
||||
// Setting minHeight on the strip (not the clip) forces align-items:stretch to
|
||||
// make every slot exactly that tall, so mt-auto always bottoms out at the same Y.
|
||||
useLayoutEffect(() => {
|
||||
if (!isMobile) return;
|
||||
const el = stripRef.current;
|
||||
if (!el) return;
|
||||
el.style.minHeight = '';
|
||||
const h = el.scrollHeight;
|
||||
if (h > maxClipHeightRef.current) maxClipHeightRef.current = h;
|
||||
el.style.minHeight = `${maxClipHeightRef.current}px`;
|
||||
});
|
||||
|
||||
function announceIndex(newIdx: number, total: number) {
|
||||
setPageAnnouncement(
|
||||
t('system_notice.pager.position')
|
||||
.replace('{current}', String(newIdx + 1))
|
||||
.replace('{total}', String(total)),
|
||||
);
|
||||
}
|
||||
|
||||
// Dismiss current notice. The store removes it from the array, and the next
|
||||
// notice naturally shifts into notices[idx]. The clamp effect handles the
|
||||
// edge case where idx was pointing at the last item.
|
||||
function handleDismissById(id: string) {
|
||||
setVisible(false);
|
||||
dismiss(id);
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
const id = noticeIdRef.current;
|
||||
if (id) handleDismissById(id);
|
||||
}
|
||||
|
||||
// Dismiss every notice in the current modal list — used by the X button and ESC.
|
||||
function handleDismissAll() {
|
||||
setVisible(false);
|
||||
notices.forEach(n => dismiss(n.id));
|
||||
}
|
||||
|
||||
function handleCTA() {
|
||||
if (!notice) return;
|
||||
if (!notice.cta) {
|
||||
handleDismissAll();
|
||||
return;
|
||||
}
|
||||
if (notice.cta.kind === 'nav') {
|
||||
navigate(notice.cta.href);
|
||||
if (notice.dismissible !== false) handleDismissAll();
|
||||
} else {
|
||||
runNoticeAction(notice.cta.actionId, { navigate });
|
||||
const actionCta = notice.cta as { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean };
|
||||
if (actionCta.dismissOnAction !== false) handleDismissAll();
|
||||
}
|
||||
}
|
||||
|
||||
function animatedDismissAll() {
|
||||
const sheet = sheetRef.current;
|
||||
if (!sheet || prefersReducedMotion) { handleDismissAll(); return; }
|
||||
sheet.style.transition = 'transform 300ms ease-out';
|
||||
sheet.style.transform = 'translateY(110%)';
|
||||
sheet.addEventListener('transitionend', function onDone() {
|
||||
sheet.removeEventListener('transitionend', onDone);
|
||||
handleDismissAll();
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
// Sets up the content wrapper's start transform SYNCHRONOUSLY (before React
|
||||
// re-renders with the new notice), then flags the grace-delay effect to slide
|
||||
// rather than hide+show.
|
||||
function triggerPageSlide(dir: 'left' | 'right') {
|
||||
isPageNavRef.current = true;
|
||||
slideDirRef.current = dir;
|
||||
if (!prefersReducedMotion) {
|
||||
const el = contentWrapperRef.current;
|
||||
if (el) {
|
||||
el.style.transition = 'none';
|
||||
el.style.transform = dir === 'right' ? 'translateX(100%)' : 'translateX(-100%)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handlePrev() {
|
||||
if (!canPage || idx <= 0) return;
|
||||
const next = idx - 1;
|
||||
triggerPageSlide('left');
|
||||
setIdx(next);
|
||||
announceIndex(next, notices.length);
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
if (!canPage || idx >= notices.length - 1) return;
|
||||
const next = idx + 1;
|
||||
triggerPageSlide('right');
|
||||
setIdx(next);
|
||||
announceIndex(next, notices.length);
|
||||
}
|
||||
|
||||
function handleGoto(i: number) {
|
||||
if (!canPage || i === idx) return;
|
||||
triggerPageSlide(i > idx ? 'right' : 'left');
|
||||
setIdx(i);
|
||||
announceIndex(i, notices.length);
|
||||
}
|
||||
|
||||
// No notice to show
|
||||
if (!notice) return null;
|
||||
|
||||
// Pre-compute body with params interpolated
|
||||
const rawBody = t(notice.bodyKey);
|
||||
const body = notice.bodyParams
|
||||
? Object.entries(notice.bodyParams).reduce(
|
||||
(s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), v),
|
||||
rawBody
|
||||
)
|
||||
: rawBody;
|
||||
|
||||
const title = t(notice.titleKey);
|
||||
const ctaLabel = notice.cta ? t(notice.cta.labelKey) : null;
|
||||
|
||||
const titleId = `notice-title-${notice.id}`;
|
||||
const bodyId = `notice-body-${notice.id}`;
|
||||
|
||||
// Animation classes
|
||||
const dur = prefersReducedMotion ? 'duration-[120ms]' : 'duration-[260ms]';
|
||||
const ease = visible ? 'ease-out' : 'ease-in';
|
||||
|
||||
const contentProps: ContentProps = {
|
||||
notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
||||
onDismiss: handleDismiss,
|
||||
onDismissAll: handleDismissAll,
|
||||
onCTA: handleCTA,
|
||||
total: notices.length,
|
||||
currentPage: idx,
|
||||
canPage,
|
||||
onPrev: handlePrev,
|
||||
onNext: handleNext,
|
||||
onGoto: handleGoto,
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
const mobileMotion = prefersReducedMotion
|
||||
? (visible ? 'opacity-100' : 'opacity-0')
|
||||
: (visible ? 'opacity-100 translate-y-0' : 'opacity-100 translate-y-full');
|
||||
|
||||
// Build ContentProps for an adjacent slot so NoticeContent renders correctly
|
||||
function buildSlotProps(n: SystemNoticeDTO, slotIdx: number): ContentProps {
|
||||
const slotRawBody = t(n.bodyKey);
|
||||
const slotBody = n.bodyParams
|
||||
? Object.entries(n.bodyParams).reduce(
|
||||
(s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), v),
|
||||
slotRawBody
|
||||
)
|
||||
: slotRawBody;
|
||||
return {
|
||||
notice: n,
|
||||
title: t(n.titleKey),
|
||||
body: slotBody,
|
||||
ctaLabel: n.cta ? t(n.cta.labelKey) : null,
|
||||
titleId: `notice-title-${n.id}`,
|
||||
bodyId: `notice-body-${n.id}`,
|
||||
isDark,
|
||||
onDismiss: handleDismiss,
|
||||
onDismissAll: handleDismissAll,
|
||||
onCTA: handleCTA,
|
||||
total: notices.length,
|
||||
currentPage: slotIdx,
|
||||
canPage,
|
||||
onPrev: handlePrev,
|
||||
onNext: handleNext,
|
||||
onGoto: handleGoto,
|
||||
};
|
||||
}
|
||||
|
||||
const prevNotice = notices[idx - 1] ?? null;
|
||||
const nextNotice = notices[idx + 1] ?? null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50" role="presentation">
|
||||
{/* Screen-reader page announcements */}
|
||||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{pageAnnouncement}</span>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
|
||||
onClick={notice.dismissible ? animatedDismissAll : undefined}
|
||||
/>
|
||||
{/* Bottom sheet */}
|
||||
<div
|
||||
ref={sheetRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={bodyId}
|
||||
className={`absolute bottom-0 left-0 right-0 rounded-t-3xl overflow-hidden max-h-[85dvh] overflow-y-auto bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl transition-[opacity,transform] ${dur} ${ease} ${mobileMotion}`}
|
||||
style={{ paddingBottom: 'var(--bottom-nav-h)', touchAction: 'pan-y' }}
|
||||
onTouchStart={e => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
touchStartY.current = e.touches[0].clientY;
|
||||
dragLockRef.current = null;
|
||||
}}
|
||||
onTouchMove={e => {
|
||||
if (prefersReducedMotion) return;
|
||||
const startX = touchStartX.current;
|
||||
const startY = touchStartY.current;
|
||||
if (startX === null || startY === null) return;
|
||||
const dx = e.touches[0].clientX - startX;
|
||||
const dy = e.touches[0].clientY - startY;
|
||||
// Classify gesture direction on first significant movement
|
||||
if (!dragLockRef.current) {
|
||||
if (Math.abs(dx) > 8 || Math.abs(dy) > 8)
|
||||
dragLockRef.current = Math.abs(dx) >= Math.abs(dy) ? 'h' : 'v';
|
||||
return;
|
||||
}
|
||||
if (dragLockRef.current === 'h') {
|
||||
const strip = stripRef.current;
|
||||
if (!strip) return;
|
||||
strip.style.transition = 'none';
|
||||
// Strip base = -33.333% (center slot visible); dx offsets from there
|
||||
strip.style.transform = `translateX(calc(-33.333% + ${dx}px))`;
|
||||
} else if (dragLockRef.current === 'v' && notice.dismissible) {
|
||||
const sheet = sheetRef.current;
|
||||
if (!sheet || dy <= 0) return;
|
||||
sheet.style.transition = 'none';
|
||||
sheet.style.transform = `translateY(${dy}px)`;
|
||||
}
|
||||
}}
|
||||
onTouchEnd={e => {
|
||||
const startX = touchStartX.current;
|
||||
const startY = touchStartY.current;
|
||||
touchStartX.current = null;
|
||||
touchStartY.current = null;
|
||||
const lock = dragLockRef.current;
|
||||
dragLockRef.current = null;
|
||||
|
||||
if (lock === 'h') {
|
||||
if (startX === null) return;
|
||||
const deltaX = e.changedTouches[0].clientX - startX;
|
||||
const strip = stripRef.current;
|
||||
if (!strip) return;
|
||||
|
||||
const goNext = isRtlLanguage(language) ? deltaX > 50 : deltaX < -50;
|
||||
const goPrev = isRtlLanguage(language) ? deltaX < -50 : deltaX > 50;
|
||||
const canGoNext = canPage && idx < notices.length - 1;
|
||||
const canGoPrev = canPage && idx > 0;
|
||||
|
||||
if ((goNext && canGoNext) || (goPrev && canGoPrev)) {
|
||||
// Animate strip to the adjacent slot (-66.666% = next, 0% = prev)
|
||||
strip.style.transition = 'transform 200ms ease-out';
|
||||
strip.style.transform = goNext ? 'translateX(-66.666%)' : 'translateX(0%)';
|
||||
strip.addEventListener('transitionend', function onDone() {
|
||||
strip.removeEventListener('transitionend', onDone);
|
||||
strip.style.transition = 'none';
|
||||
// Render new content into the center slot BEFORE moving the strip,
|
||||
// so the browser never paints old content at the center position.
|
||||
const newIdx = goNext ? idx + 1 : idx - 1;
|
||||
flushSync(() => {
|
||||
isPageNavRef.current = true;
|
||||
setIdx(newIdx);
|
||||
announceIndex(newIdx, notices.length);
|
||||
});
|
||||
strip.style.transform = 'translateX(-33.333%)';
|
||||
}, { once: true });
|
||||
} else {
|
||||
// Spring back to center
|
||||
strip.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)';
|
||||
strip.style.transform = 'translateX(-33.333%)';
|
||||
strip.addEventListener('transitionend', function onSnap() {
|
||||
strip.removeEventListener('transitionend', onSnap);
|
||||
strip.style.transition = '';
|
||||
strip.style.transform = 'translateX(-33.333%)';
|
||||
}, { once: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Vertical drag — animated dismiss or spring back
|
||||
if (lock === 'v' && startY !== null) {
|
||||
const deltaY = e.changedTouches[0].clientY - startY;
|
||||
const sheet = sheetRef.current;
|
||||
if (deltaY > 80 && notice.dismissible) {
|
||||
animatedDismissAll();
|
||||
} else if (sheet && deltaY > 0) {
|
||||
sheet.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)';
|
||||
sheet.style.transform = 'translateY(0)';
|
||||
sheet.addEventListener('transitionend', function onSnap() {
|
||||
sheet.removeEventListener('transitionend', onSnap);
|
||||
sheet.style.transition = '';
|
||||
sheet.style.transform = '';
|
||||
}, { once: true });
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="pt-3 pb-1 flex justify-center">
|
||||
<div className="w-9 h-1 rounded-full bg-slate-300 dark:bg-slate-600" />
|
||||
</div>
|
||||
{/* Clip container — hides the adjacent slots outside the sheet width */}
|
||||
<div style={{ overflow: 'hidden', width: '100%' }}>
|
||||
{/* 3-slot strip: [prev][current][next] — starts at -33.333% to show current */}
|
||||
<div
|
||||
ref={stripRef}
|
||||
style={{ display: 'flex', width: '300%', alignItems: 'stretch', transform: 'translateX(-33.333%)' }}
|
||||
>
|
||||
<div style={{ width: '33.333%', display: 'flex', flexDirection: 'column' }}>
|
||||
{prevNotice && <NoticeContent {...buildSlotProps(prevNotice, idx - 1)} />}
|
||||
</div>
|
||||
<div ref={contentWrapperRef} style={{ width: '33.333%', display: 'flex', flexDirection: 'column' }}>
|
||||
<NoticeContent {...contentProps} />
|
||||
</div>
|
||||
<div style={{ width: '33.333%', display: 'flex', flexDirection: 'column' }}>
|
||||
{nextNotice && <NoticeContent {...buildSlotProps(nextNotice, idx + 1)} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop centered modal
|
||||
const maxWidth = notice.severity === 'critical' ? 'max-w-[680px]' : 'max-w-[620px]';
|
||||
const desktopMotion = prefersReducedMotion
|
||||
? (visible ? 'opacity-100' : 'opacity-0')
|
||||
: (visible ? 'opacity-100 scale-100' : 'opacity-0 scale-[0.97]');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 z-50 bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
|
||||
role="presentation"
|
||||
onClick={notice.dismissible && isLastPage ? handleDismissAll : undefined}
|
||||
>
|
||||
{/* Screen-reader page announcements */}
|
||||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{pageAnnouncement}</span>
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={bodyId}
|
||||
className={`w-full ${maxWidth} rounded-2xl overflow-hidden overflow-y-auto max-h-[90vh] shadow-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 transition-all ${dur} ${ease} ${desktopMotion}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div ref={contentWrapperRef}>
|
||||
<NoticeContent {...contentProps} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
export interface NoticeActionContext {
|
||||
navigate: NavigateFunction;
|
||||
}
|
||||
type NoticeActionHandler = (ctx: NoticeActionContext) => void | Promise<void>;
|
||||
|
||||
const actions = new Map<string, NoticeActionHandler>();
|
||||
|
||||
export function registerNoticeAction(id: string, handler: NoticeActionHandler): void {
|
||||
actions.set(id, handler);
|
||||
}
|
||||
|
||||
export function runNoticeAction(id: string, ctx: NoticeActionContext): void {
|
||||
const handler = actions.get(id);
|
||||
if (!handler) {
|
||||
console.error(`[systemNotices] unknown action CTA id: "${id}". Register it via registerNoticeAction().`);
|
||||
return;
|
||||
}
|
||||
void handler(ctx);
|
||||
}
|
||||
@@ -394,7 +394,7 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
|
||||
)}
|
||||
{selectedItem && !isAddingNew && isMobile && (
|
||||
<div onClick={e => { if (e.target === e.currentTarget) setSelectedId(null) }}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
|
||||
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}>
|
||||
<DetailPane
|
||||
@@ -419,7 +419,7 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
|
||||
)}
|
||||
{isAddingNew && !selectedItem && isMobile && (
|
||||
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
|
||||
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}>
|
||||
<NewTaskPane
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function Modal({
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 modal-backdrop"
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 20, overflow: 'hidden' }}
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
|
||||
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) onClose()
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
/** Returns true when the viewport is below the lg breakpoint (1024px). */
|
||||
export function useIsMobile(): boolean {
|
||||
const [isMobile, setIsMobile] = useState(
|
||||
() => typeof window !== 'undefined' && window.innerWidth < 1024,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 1023px)')
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
|
||||
setIsMobile(mq.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [])
|
||||
|
||||
return isMobile
|
||||
}
|
||||
@@ -464,6 +464,12 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.audit': 'تدقيق',
|
||||
'admin.tabs.settings': 'الإعدادات',
|
||||
'admin.tabs.config': 'التخصيص',
|
||||
'admin.tabs.defaults': 'الإعدادات الافتراضية',
|
||||
'admin.defaultSettings.title': 'إعدادات المستخدم الافتراضية',
|
||||
'admin.defaultSettings.description': 'تعيين الإعدادات الافتراضية على مستوى النظام. سيرى المستخدمون الذين لم يغيروا إعدادًا هذه القيم. تحظى تغييراتهم دائمًا بالأولوية.',
|
||||
'admin.defaultSettings.saved': 'تم حفظ الإعداد الافتراضي',
|
||||
'admin.defaultSettings.reset': 'إعادة التعيين إلى الإعداد الافتراضي المدمج',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'إعادة تعيين',
|
||||
'admin.tabs.templates': 'قوالب التعبئة',
|
||||
'admin.tabs.addons': 'الإضافات',
|
||||
'admin.tabs.mcpTokens': 'وصول MCP',
|
||||
@@ -584,6 +590,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Packing Templates & Bag Tracking
|
||||
'admin.bagTracking.title': 'تتبع الأمتعة',
|
||||
'admin.bagTracking.subtitle': 'تفعيل الوزن وتعيين الأمتعة للعناصر',
|
||||
'admin.collab.chat.title': 'الدردشة',
|
||||
'admin.collab.chat.subtitle': 'المراسلة في الوقت الفعلي للتعاون',
|
||||
'admin.collab.notes.title': 'الملاحظات',
|
||||
'admin.collab.notes.subtitle': 'ملاحظات ومستندات مشتركة',
|
||||
'admin.collab.polls.title': 'الاستطلاعات',
|
||||
'admin.collab.polls.subtitle': 'استطلاعات وتصويت جماعي',
|
||||
'admin.collab.whatsnext.title': 'ما التالي',
|
||||
'admin.collab.whatsnext.subtitle': 'اقتراحات الأنشطة والخطوات التالية',
|
||||
'admin.packingTemplates.title': 'قوالب التعبئة',
|
||||
'admin.packingTemplates.subtitle': 'إنشاء قوائم تعبئة قابلة لإعادة الاستخدام',
|
||||
'admin.packingTemplates.create': 'قالب جديد',
|
||||
@@ -1007,6 +1021,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.meta.platform': 'المنصة',
|
||||
'reservations.meta.seat': 'المقعد',
|
||||
'reservations.meta.checkIn': 'تسجيل الوصول',
|
||||
'reservations.meta.checkInUntil': 'تسجيل الدخول حتى',
|
||||
'reservations.meta.checkOut': 'تسجيل المغادرة',
|
||||
'reservations.meta.linkAccommodation': 'الإقامة',
|
||||
'reservations.meta.pickAccommodation': 'ربط بالإقامة',
|
||||
@@ -1491,6 +1506,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'day.noPlacesForHotel': 'أضف أماكن إلى رحلتك أولًا',
|
||||
'day.allDays': 'الكل',
|
||||
'day.checkIn': 'تسجيل الوصول',
|
||||
'day.checkInUntil': 'حتى',
|
||||
'day.checkOut': 'تسجيل المغادرة',
|
||||
'day.confirmation': 'التأكيد',
|
||||
'day.editAccommodation': 'تعديل الإقامة',
|
||||
@@ -1963,6 +1979,50 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'البحث عن مواقع وحل عناوين الخرائط والترميز الجغرافي العكسي للإحداثيات',
|
||||
'oauth.scope.weather:read.label': 'توقعات الطقس',
|
||||
'oauth.scope.weather:read.description': 'جلب توقعات الطقس لمواقع الرحلة وتواريخها',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'مرحبًا بك في TREK',
|
||||
'system_notice.welcome_v1.body': 'مخطط رحلاتك الشامل. أنشئ جداول السفر، وشارك رحلاتك مع الأصدقاء، وابقَ منظمًا — سواء كنت متصلاً بالإنترنت أم لا.',
|
||||
'system_notice.welcome_v1.cta_label': 'خطط لرحلة',
|
||||
'system_notice.welcome_v1.hero_alt': 'وجهة سفر خلابة مع واجهة تطبيق TREK',
|
||||
'system_notice.welcome_v1.highlight_plan': 'جداول رحلات يومية لكل سفرة',
|
||||
'system_notice.welcome_v1.highlight_share': 'تعاون مع شركاء السفر',
|
||||
'system_notice.welcome_v1.highlight_offline': 'يعمل بلا إنترنت على الهاتف',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'الإشعار السابق',
|
||||
'system_notice.pager.next': 'الإشعار التالي',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'الانتقال إلى الإشعار {n}',
|
||||
'system_notice.pager.position': 'الإشعار {current} من {total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'تم نقل الصور في الإصدار 3.0',
|
||||
'system_notice.v3_photos.body': 'تمت إزالة تبويب **الصور** من مخطط الرحلة. صورك آمنة — لم يعدّل TREK مكتبتك على Immich أو Synology قطّ.\n\nتعيش الصور الآن في إضافة **Journey**. Journey اختيارية — إن لم تكن متاحة بعد، اطلب من المسؤول تفعيلها عبر Admin ← الإضافات.',
|
||||
'system_notice.v3_journey.title': 'تعرّف على Journey — مذكرة سفر',
|
||||
'system_notice.v3_journey.body': 'وثّق رحلاتك كقصص غنية بخطوط زمنية ومعارض صور وخرائط تفاعلية.',
|
||||
'system_notice.v3_journey.cta_label': 'فتح Journey',
|
||||
'system_notice.v3_journey.highlight_timeline': 'جدول زمني يومي ومعرض',
|
||||
'system_notice.v3_journey.highlight_photos': 'استيراد من Immich أو Synology',
|
||||
'system_notice.v3_journey.highlight_share': 'مشاركة علنية — دون تسجيل دخول',
|
||||
'system_notice.v3_journey.highlight_export': 'تصدير كألبوم صور PDF',
|
||||
'system_notice.v3_features.title': 'مزيد من مميزات 3.0',
|
||||
'system_notice.v3_features.body': 'بعض الجديد الآخر الجدير بالمعرفة في هذا الإصدار.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'إعادة تصميم لوحة التحكم mobile-first',
|
||||
'system_notice.v3_features.highlight_offline': 'وضع لا اتصال كامل كتطبيق PWA',
|
||||
'system_notice.v3_features.highlight_search': 'إكمال تلقائي في الوقت الفعلي',
|
||||
'system_notice.v3_features.highlight_import': 'استيراد أماكن من ملفات KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: ترقية OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'تمت إعادة تصميم تكامل MCP بالكامل. OAuth 2.1 هو الآن طريقة المصادقة الموصى بها. الرموز الثابتة (trek_…) مهملة وستُزال في إصدار مستقبلي.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 موصى به (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 نطاق أذونات دقيق',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'الرموز الثابتة trek_ مهملة',
|
||||
'system_notice.v3_mcp.highlight_tools': 'مجموعة أدوات وإرشادات موسعة',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'كلمة شخصية مني',
|
||||
'system_notice.v3_thankyou.body': 'قبل أن تمضي — أريد أن أتوقف لحظة.\n\nبدأ TREK كمشروع جانبي بنيته لرحلاتي الخاصة. لم أتخيل يومًا أنه سيكبر ليصبح شيئًا يعتمد عليه 4,000 منكم لتخطيط مغامراتهم. كل نجمة، كل مشكلة، كل طلب ميزة — أقرأها جميعًا، وهي ما يبقيني مستمرًا في الليالي المتأخرة بين عمل بدوام كامل والجامعة.\n\nأريدكم أن تعرفوا: TREK سيبقى دائمًا مفتوح المصدر، دائمًا مستضافًا ذاتيًا، دائمًا ملككم. لا تتبع، لا اشتراكات، لا شروط خفية. مجرد أداة بناها شخص يحب السفر بقدر ما تحبونه.\n\nشكر خاص لـ [jubnl](https://github.com/jubnl) — لقد أصبحت متعاونًا رائعًا. الكثير مما يجعل الإصدار 3.0 عظيمًا يحمل بصماتك. شكرًا لإيمانك بهذا المشروع عندما كان لا يزال في بداياته.\n\nولكل واحد منكم ممن أبلغ عن خطأ، أو ترجم نصًا، أو شارك TREK مع صديق، أو ببساطة استخدمه لتخطيط رحلة — **شكرًا لكم**. أنتم السبب في وجود هذا.\n\nإلى المزيد من المغامرات معًا.\n\n— Maurice\n\n---\n\n[انضم إلى المجتمع على Discord](https://discord.gg/7Q6M6jDwzf)\n\nإذا جعل TREK رحلاتك أفضل، [فنجان قهوة صغير](https://ko-fi.com/mauriceboe) يبقي الأضواء مشتعلة.',
|
||||
}
|
||||
|
||||
export default ar
|
||||
|
||||
@@ -548,7 +548,21 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Packing Templates & Bag Tracking
|
||||
'admin.bagTracking.title': 'Rastreamento de malas',
|
||||
'admin.bagTracking.subtitle': 'Ativar peso e atribuição de mala para itens da lista',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
'admin.collab.chat.subtitle': 'Mensagens em tempo real para colaboração',
|
||||
'admin.collab.notes.title': 'Notas',
|
||||
'admin.collab.notes.subtitle': 'Notas e documentos compartilhados',
|
||||
'admin.collab.polls.title': 'Enquetes',
|
||||
'admin.collab.polls.subtitle': 'Enquetes e votações em grupo',
|
||||
'admin.collab.whatsnext.title': 'Próximos passos',
|
||||
'admin.collab.whatsnext.subtitle': 'Sugestões de atividades e próximos passos',
|
||||
'admin.tabs.config': 'Personalização',
|
||||
'admin.tabs.defaults': 'Padrões do usuário',
|
||||
'admin.defaultSettings.title': 'Configurações padrão do usuário',
|
||||
'admin.defaultSettings.description': 'Defina padrões para toda a instância. Usuários que não alteraram uma configuração verão esses valores. As próprias alterações deles sempre têm prioridade.',
|
||||
'admin.defaultSettings.saved': 'Padrão salvo',
|
||||
'admin.defaultSettings.reset': 'Redefinir para o padrão integrado',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'redefinir',
|
||||
'admin.tabs.templates': 'Modelos de mala',
|
||||
'admin.packingTemplates.title': 'Modelos de mala',
|
||||
'admin.packingTemplates.subtitle': 'Crie listas de mala reutilizáveis para suas viagens',
|
||||
@@ -976,6 +990,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.meta.platform': 'Plataforma',
|
||||
'reservations.meta.seat': 'Assento',
|
||||
'reservations.meta.checkIn': 'Check-in',
|
||||
'reservations.meta.checkInUntil': 'Check-in até',
|
||||
'reservations.meta.checkOut': 'Check-out',
|
||||
'reservations.meta.linkAccommodation': 'Hospedagem',
|
||||
'reservations.meta.pickAccommodation': 'Vincular à hospedagem',
|
||||
@@ -1460,6 +1475,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'day.noPlacesForHotel': 'Adicione lugares à viagem primeiro',
|
||||
'day.allDays': 'Todos',
|
||||
'day.checkIn': 'Check-in',
|
||||
'day.checkInUntil': 'Até',
|
||||
'day.checkOut': 'Check-out',
|
||||
'day.confirmation': 'Confirmação',
|
||||
'day.editAccommodation': 'Editar hospedagem',
|
||||
@@ -2166,6 +2182,50 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Pesquisar locais, resolver URLs de mapa e geocodificar coordenadas',
|
||||
'oauth.scope.weather:read.label': 'Previsão do tempo',
|
||||
'oauth.scope.weather:read.description': 'Obter previsão do tempo para locais e datas da viagem',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Bem-vindo ao TREK',
|
||||
'system_notice.welcome_v1.body': 'Seu planejador de viagens tudo-em-um. Crie roteiros, compartilhe viagens com amigos e fique organizado — online ou offline.',
|
||||
'system_notice.welcome_v1.cta_label': 'Planejar uma viagem',
|
||||
'system_notice.welcome_v1.hero_alt': 'Destino de viagem pitoresco com a interface do TREK',
|
||||
'system_notice.welcome_v1.highlight_plan': 'Roteiros dia a dia para qualquer viagem',
|
||||
'system_notice.welcome_v1.highlight_share': 'Colabore com seus companheiros de viagem',
|
||||
'system_notice.welcome_v1.highlight_offline': 'Funciona offline no celular',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'Aviso anterior',
|
||||
'system_notice.pager.next': 'Próximo aviso',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'Ir para o aviso {n}',
|
||||
'system_notice.pager.position': 'Aviso {current} de {total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'Fotos foram movidas na versão 3.0',
|
||||
'system_notice.v3_photos.body': '**Fotos** no Planejador de Viagens foram removidas. Suas fotos estão seguras — o TREK nunca modificou sua biblioteca Immich ou Synology.\n\nAs fotos agora vivem no addon **Journey**. Journey é opcional — se ainda não estiver disponível, peça ao seu admin para ativá-lo em Admin → Addons.',
|
||||
'system_notice.v3_journey.title': 'Conheça o Journey — diário de viagem',
|
||||
'system_notice.v3_journey.body': 'Documente suas viagens como histórias ricas com cronologias, galerias de fotos e mapas interativos.',
|
||||
'system_notice.v3_journey.cta_label': 'Abrir Journey',
|
||||
'system_notice.v3_journey.highlight_timeline': 'Linha do tempo e galeria diária',
|
||||
'system_notice.v3_journey.highlight_photos': 'Importar do Immich ou Synology',
|
||||
'system_notice.v3_journey.highlight_share': 'Compartilhar publicamente — sem login',
|
||||
'system_notice.v3_journey.highlight_export': 'Exportar como álbum de fotos PDF',
|
||||
'system_notice.v3_features.title': 'Mais destaques na versão 3.0',
|
||||
'system_notice.v3_features.body': 'Algumas outras novidades que vale a pena conhecer nesta versão.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'Redesign do painel mobile-first',
|
||||
'system_notice.v3_features.highlight_offline': 'Modo offline completo como PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Autocompleção de lugares em tempo real',
|
||||
'system_notice.v3_features.highlight_import': 'Importar lugares de arquivos KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: atualização OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'A integração MCP foi completamente reformulada. OAuth 2.1 agora é o método de autenticação recomendado. Tokens estáticos (trek_…) foram descontinuados e serão removidos em uma versão futura.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 recomendado (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 escopos de permissão granulares',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Tokens estáticos trek_ descontinuados',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Conjunto de ferramentas e prompts expandido',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Uma nota pessoal minha',
|
||||
'system_notice.v3_thankyou.body': 'Antes de seguir em frente — quero fazer uma pausa.\n\nO TREK começou como um projeto paralelo que criei para minhas próprias viagens. Nunca imaginei que cresceria a ponto de 4.000 de vocês confiarem nele para planejar suas aventuras. Cada estrela, cada issue, cada pedido de recurso — eu leio todos, e eles me mantêm firme nas noites longas entre um trabalho em tempo integral e a universidade.\n\nQuero que saibam: o TREK sempre será open source, sempre self-hosted, sempre de vocês. Sem rastreamento, sem assinaturas, sem pegadinhas. Apenas uma ferramenta feita por alguém que ama viajar tanto quanto vocês.\n\nAgradecimento especial ao [jubnl](https://github.com/jubnl) — você se tornou um colaborador incrível. Muito do que torna a versão 3.0 especial tem a sua marca. Obrigado por acreditar neste projeto quando ele ainda era bem cru.\n\nE a cada um de vocês que reportou um bug, traduziu uma string, compartilhou o TREK com um amigo ou simplesmente o usou para planejar uma viagem — **obrigado**. Vocês são a razão de tudo isso existir.\n\nQue venham muitas mais aventuras juntos.\n\n— Maurice\n\n---\n\n[Junte-se à comunidade no Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe o TREK torna suas viagens melhores, um [cafezinho](https://ko-fi.com/mauriceboe) sempre mantém as luzes acesas.',
|
||||
}
|
||||
|
||||
export default br
|
||||
|
||||
@@ -548,7 +548,21 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Šablony balení (Packing Templates)
|
||||
'admin.bagTracking.title': 'Sledování zavazadel',
|
||||
'admin.bagTracking.subtitle': 'Povolit váhu a přiřazení k zavazadlům u položek balení',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
'admin.collab.chat.subtitle': 'Zasílání zpráv v reálném čase',
|
||||
'admin.collab.notes.title': 'Poznámky',
|
||||
'admin.collab.notes.subtitle': 'Sdílené poznámky a dokumenty',
|
||||
'admin.collab.polls.title': 'Ankety',
|
||||
'admin.collab.polls.subtitle': 'Skupinové ankety a hlasování',
|
||||
'admin.collab.whatsnext.title': 'Co dál',
|
||||
'admin.collab.whatsnext.subtitle': 'Návrhy aktivit a další kroky',
|
||||
'admin.tabs.config': 'Personalizace',
|
||||
'admin.tabs.defaults': 'Výchozí nastavení uživatele',
|
||||
'admin.defaultSettings.title': 'Výchozí nastavení uživatele',
|
||||
'admin.defaultSettings.description': 'Nastavte výchozí hodnoty pro celou instanci. Uživatelé, kteří nezměnili nastavení, uvidí tyto hodnoty. Jejich vlastní změny mají vždy přednost.',
|
||||
'admin.defaultSettings.saved': 'Výchozí nastavení uloženo',
|
||||
'admin.defaultSettings.reset': 'Obnovit na vestavěnou výchozí hodnotu',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'obnovit',
|
||||
'admin.tabs.templates': 'Šablony seznamů',
|
||||
'admin.packingTemplates.title': 'Šablony pro balení',
|
||||
'admin.packingTemplates.subtitle': 'Vytvářejte opakovaně použitelné seznamy pro své cesty',
|
||||
@@ -1005,6 +1019,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.meta.platform': 'Nástupiště',
|
||||
'reservations.meta.seat': 'Sedadlo',
|
||||
'reservations.meta.checkIn': 'Check-in',
|
||||
'reservations.meta.checkInUntil': 'Check-in do',
|
||||
'reservations.meta.checkOut': 'Check-out',
|
||||
'reservations.meta.linkAccommodation': 'Ubytování',
|
||||
'reservations.meta.pickAccommodation': 'Propojit s ubytováním',
|
||||
@@ -1489,6 +1504,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'day.noPlacesForHotel': 'Nejprve přidejte místa ke své cestě',
|
||||
'day.allDays': 'Vše',
|
||||
'day.checkIn': 'Check-in',
|
||||
'day.checkInUntil': 'Do',
|
||||
'day.checkOut': 'Check-out',
|
||||
'day.confirmation': 'Potvrzení',
|
||||
'day.editAccommodation': 'Upravit ubytování',
|
||||
@@ -2170,6 +2186,50 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Vyhledávat místa, řešit URL map a zpětně geokódovat souřadnice',
|
||||
'oauth.scope.weather:read.label': 'Předpovědi počasí',
|
||||
'oauth.scope.weather:read.description': 'Získávat předpovědi počasí pro místa a data výletu',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Vítejte v TREK',
|
||||
'system_notice.welcome_v1.body': 'Váš kompletní plánovač cest. Vytvářejte itineráře, sdílejte výlety s přáteli a zůstaňte organizovaní — online i offline.',
|
||||
'system_notice.welcome_v1.cta_label': 'Naplánovat cestu',
|
||||
'system_notice.welcome_v1.hero_alt': 'Malebné cestovní místo s rozhraním TREK',
|
||||
'system_notice.welcome_v1.highlight_plan': 'Denní itineráře pro každou cestu',
|
||||
'system_notice.welcome_v1.highlight_share': 'Spolupráce s cestovními partnery',
|
||||
'system_notice.welcome_v1.highlight_offline': 'Funguje offline na mobilu',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'Předchozí oznámení',
|
||||
'system_notice.pager.next': 'Další oznámení',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'Přejít na oznámení {n}',
|
||||
'system_notice.pager.position': 'Oznámení {current} z {total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'Fotografie přesunuty ve verzi 3.0',
|
||||
'system_notice.v3_photos.body': '**Fotografie** v Plánovacím nástroji byly odebrány. Vaše fotografie jsou v bezpečí — TREK nikdy neupravoval vaši knihovnu Immich nebo Synology.\n\nFotografie jsou nyní dostupné v doplňku **Journey**. Journey je volitelný — pokud ještě není k dispozici, požádejte svého správce, aby ho aktivoval v Admin → Doplňky.',
|
||||
'system_notice.v3_journey.title': 'Poznejte Journey — cest. denník',
|
||||
'system_notice.v3_journey.body': 'Dokumentujte své cesty jako bohaté příběhy s časovnicemi, galeriemi fotek a interaktivními mapami.',
|
||||
'system_notice.v3_journey.cta_label': 'Otevřít Journey',
|
||||
'system_notice.v3_journey.highlight_timeline': 'Denní časovnice a galerie',
|
||||
'system_notice.v3_journey.highlight_photos': 'Import z Immich nebo Synology',
|
||||
'system_notice.v3_journey.highlight_share': 'Sdílet veřejně — bez přihlašování',
|
||||
'system_notice.v3_journey.highlight_export': 'Export jako PDF fotokniha',
|
||||
'system_notice.v3_features.title': 'Další novinky ve verzi 3.0',
|
||||
'system_notice.v3_features.body': 'Několik dalších změn, které stojí za pozornost.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'Předesign dashboardu mobile-first',
|
||||
'system_notice.v3_features.highlight_offline': 'Plný offline režim jako PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Autodoplňování vyhledávání míst',
|
||||
'system_notice.v3_features.highlight_import': 'Import míst ze souborů KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: aktualizace OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'Integrace MCP byla kompletně přepracována. OAuth 2.1 je nyní doporučenou metodou ověřování. Statické tokeny (trek_…) jsou zastaralé a budou v budoucí verzi odstraněny.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 doporučeno (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 jemnozrnných oprávnění',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Statické tokeny trek_ zastaralé',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Rozšířená sada nástrojů a promptů',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Osobní slovo ode mě',
|
||||
'system_notice.v3_thankyou.body': 'Než budete pokračovat — chci se na chvíli zastavit.\n\nTREK začal jako vedlejší projekt, který jsem vytvořil pro své vlastní cesty. Nikdy jsem si nepředstavoval, že vyroste v něco, čemu 4 000 z vás důvěřuje při plánování svých dobrodružství. Každou hvězdičku, každý issue, každý požadavek na funkci — všechny čtu a právě ony mě drží při životě během pozdních nocí mezi prací na plný úvazek a univerzitou.\n\nChci, abyste věděli: TREK bude vždy open source, vždy self-hosted, vždy váš. Žádné sledování, žádná předplatná, žádné háčky. Jen nástroj vytvořený někým, kdo miluje cestování stejně jako vy.\n\nZvláštní poděkování patří [jubnl](https://github.com/jubnl) — stal ses neuvěřitelným spolupracovníkem. Tolik z toho, co dělá verzi 3.0 skvělou, nese tvůj rukopis. Děkuji, že jsi věřil tomuto projektu, když byl ještě v plenkách.\n\nA každému z vás, kdo nahlásil chybu, přeložil řetězec, sdílel TREK s přítelem nebo ho jednoduše použil k plánování cesty — **děkuji**. Vy jste důvod, proč tohle existuje.\n\nNa mnoho dalších dobrodružství společně.\n\n— Maurice\n\n---\n\n[Přidej se ke komunitě na Discordu](https://discord.gg/7Q6M6jDwzf)\n\nPokud ti TREK zlepšuje cestování, [malá káva](https://ko-fi.com/mauriceboe) vždy pomůže udržet světla rozsvícená.',
|
||||
}
|
||||
|
||||
export default cs
|
||||
|
||||
@@ -552,7 +552,21 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Packing Templates & Bag Tracking
|
||||
'admin.bagTracking.title': 'Gepäck-Tracking',
|
||||
'admin.bagTracking.subtitle': 'Gewicht und Gepäckstück-Zuordnung für Packlisteneinträge aktivieren',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
'admin.collab.chat.subtitle': 'Echtzeit-Nachrichten für die Reiseplanung',
|
||||
'admin.collab.notes.title': 'Notizen',
|
||||
'admin.collab.notes.subtitle': 'Gemeinsame Notizen und Dokumente',
|
||||
'admin.collab.polls.title': 'Umfragen',
|
||||
'admin.collab.polls.subtitle': 'Gruppen-Umfragen und Abstimmungen',
|
||||
'admin.collab.whatsnext.title': 'Was kommt als Nächstes',
|
||||
'admin.collab.whatsnext.subtitle': 'Aktivitätsvorschläge und nächste Schritte',
|
||||
'admin.tabs.config': 'Personalisierung',
|
||||
'admin.tabs.defaults': 'Benutzer-Standards',
|
||||
'admin.defaultSettings.title': 'Standard-Benutzereinstellungen',
|
||||
'admin.defaultSettings.description': 'Instanzweite Standards festlegen. Benutzer, die eine Einstellung nicht geändert haben, sehen diese Werte. Eigene Änderungen haben immer Vorrang.',
|
||||
'admin.defaultSettings.saved': 'Standard gespeichert',
|
||||
'admin.defaultSettings.reset': 'Auf eingebauten Standard zurücksetzen',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'zurücksetzen',
|
||||
'admin.tabs.templates': 'Packvorlagen',
|
||||
'admin.packingTemplates.title': 'Packvorlagen',
|
||||
'admin.packingTemplates.subtitle': 'Wiederverwendbare Packlisten für deine Reisen erstellen',
|
||||
@@ -1007,6 +1021,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.meta.platform': 'Gleis',
|
||||
'reservations.meta.seat': 'Sitzplatz',
|
||||
'reservations.meta.checkIn': 'Check-in',
|
||||
'reservations.meta.checkInUntil': 'Check-in bis',
|
||||
'reservations.meta.checkOut': 'Check-out',
|
||||
'reservations.meta.linkAccommodation': 'Unterkunft',
|
||||
'reservations.meta.pickAccommodation': 'Mit Unterkunft verknüpfen',
|
||||
@@ -1491,6 +1506,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'day.noPlacesForHotel': 'Füge zuerst Orte zu deiner Reise hinzu',
|
||||
'day.allDays': 'Alle',
|
||||
'day.checkIn': 'Check-in',
|
||||
'day.checkInUntil': 'Bis',
|
||||
'day.checkOut': 'Check-out',
|
||||
'day.confirmation': 'Bestätigung',
|
||||
'day.editAccommodation': 'Unterkunft bearbeiten',
|
||||
@@ -2170,6 +2186,50 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Orte suchen, Karten-URLs auflösen und Koordinaten rückwärts geokodieren',
|
||||
'oauth.scope.weather:read.label': 'Wettervorhersagen',
|
||||
'oauth.scope.weather:read.description': 'Wettervorhersagen für Reiseorte und -daten abrufen',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Willkommen bei TREK',
|
||||
'system_notice.welcome_v1.body': 'Dein All-in-one-Reiseplaner. Erstelle Reisepläne, teile sie mit Freunden und bleib organisiert – online und offline.',
|
||||
'system_notice.welcome_v1.cta_label': 'Reise planen',
|
||||
'system_notice.welcome_v1.hero_alt': 'Malerisches Reiseziel mit TREK-Planungs-UI',
|
||||
'system_notice.welcome_v1.highlight_plan': 'Tagesweise Reisepläne für jede Reise',
|
||||
'system_notice.welcome_v1.highlight_share': 'Gemeinsam mit Reisepartnern planen',
|
||||
'system_notice.welcome_v1.highlight_offline': 'Funktioniert offline auf dem Handy',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'Vorherige Meldung',
|
||||
'system_notice.pager.next': 'Nächste Meldung',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'Zu Meldung {n}',
|
||||
'system_notice.pager.position': 'Meldung {current} von {total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'Fotos wurden in 3.0 verschoben',
|
||||
'system_notice.v3_photos.body': '**Fotos** im Trip-Planer wurden entfernt. Deine Fotos sind sicher — TREK hat deine Immich- oder Synology-Bibliothek nie verändert.\n\nFotos befinden sich jetzt im **Journey**-Addon. Journey ist optional — falls es noch nicht verfügbar ist, bitte deinen Admin, es unter Admin → Addons zu aktivieren.',
|
||||
'system_notice.v3_journey.title': 'Neu: Journey — dein Reisetagebuch',
|
||||
'system_notice.v3_journey.body': 'Dokumentiere deine Reisen als lebendige Geschichten mit Zeitachsen, Fotogalerien und interaktiven Karten.',
|
||||
'system_notice.v3_journey.cta_label': 'Journey öffnen',
|
||||
'system_notice.v3_journey.highlight_timeline': 'Zeitleiste und Galerie',
|
||||
'system_notice.v3_journey.highlight_photos': 'Import von Immich oder Synology',
|
||||
'system_notice.v3_journey.highlight_share': 'Öffentlich teilen — kein Login nötig',
|
||||
'system_notice.v3_journey.highlight_export': 'Als PDF-Fotobuch exportieren',
|
||||
'system_notice.v3_features.title': 'Weitere Highlights in 3.0',
|
||||
'system_notice.v3_features.body': 'Ein paar weitere Neuerungen in diesem Release.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'Mobile-first Dashboard-Redesign',
|
||||
'system_notice.v3_features.highlight_offline': 'Vollständiger Offline-Modus als PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Echtzeit-Autovervollständigung für Orte',
|
||||
'system_notice.v3_features.highlight_import': 'Orte aus KMZ/KML-Dateien importieren',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: OAuth 2.1-Upgrade',
|
||||
'system_notice.v3_mcp.body': 'Die MCP-Integration wurde vollständig überarbeitet. OAuth 2.1 ist jetzt die empfohlene Authentifizierungsmethode. Statische Tokens (trek_…) sind veraltet und werden in einer zukünftigen Version entfernt.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 empfohlen (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 feingranulare Berechtigungs-Scopes',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Statische trek_-Tokens veraltet',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Erweitertes Toolset & Prompts',
|
||||
|
||||
// System notices — persönlicher Dank
|
||||
'system_notice.v3_thankyou.title': 'Ein persönliches Wort von mir',
|
||||
'system_notice.v3_thankyou.body': 'Bevor du weiterklickst — einen Moment noch.\n\nTREK hat als Nebenprojekt für meine eigenen Reisen angefangen. Ich hätte nie gedacht, dass es jemals so weit kommt, dass 4.000 von euch damit ihre Abenteuer planen. Jeder Stern, jedes Issue, jeder Feature-Wunsch — ich lese sie alle, und sie halten mich am Laufen durch die späten Nächte zwischen Vollzeitjob und Studium.\n\nEins will ich euch sagen: TREK wird immer Open Source bleiben, immer self-hosted, immer eures. Kein Tracking, keine Abos, keine versteckten Haken. Einfach ein Tool, gebaut von jemandem, der das Reisen genauso liebt wie ihr.\n\nBesonderer Dank an [jubnl](https://github.com/jubnl) — du bist ein unglaublicher Mitstreiter geworden. So vieles, was 3.0 großartig macht, trägt deine Handschrift. Danke, dass du an dieses Projekt geglaubt hast, als es noch holprig war.\n\nUnd an jeden einzelnen von euch, der einen Bug gemeldet, einen String übersetzt, TREK mit Freunden geteilt oder einfach damit eine Reise geplant hat — **danke**. Ihr seid der Grund, warum es das hier gibt.\n\nAuf viele weitere Abenteuer zusammen.\n\n— Maurice\n\n---\n\n[Tritt der Community auf Discord bei](https://discord.gg/7Q6M6jDwzf)\n\nWenn TREK deine Reisen besser macht, hält ein [kleiner Kaffee](https://ko-fi.com/mauriceboe) die Lichter an.',
|
||||
}
|
||||
|
||||
export default de
|
||||
|
||||
@@ -608,7 +608,21 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Packing Templates & Bag Tracking
|
||||
'admin.bagTracking.title': 'Bag Tracking',
|
||||
'admin.bagTracking.subtitle': 'Enable weight and bag assignment for packing items',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
'admin.collab.chat.subtitle': 'Real-time messaging for trip collaboration',
|
||||
'admin.collab.notes.title': 'Notes',
|
||||
'admin.collab.notes.subtitle': 'Shared notes and documents',
|
||||
'admin.collab.polls.title': 'Polls',
|
||||
'admin.collab.polls.subtitle': 'Group polls and voting',
|
||||
'admin.collab.whatsnext.title': "What's Next",
|
||||
'admin.collab.whatsnext.subtitle': 'Activity suggestions and next steps',
|
||||
'admin.tabs.config': 'Personalization',
|
||||
'admin.tabs.defaults': 'User Defaults',
|
||||
'admin.defaultSettings.title': 'Default User Settings',
|
||||
'admin.defaultSettings.description': 'Set instance-wide defaults. Users who have not changed a setting will see these values. Their own changes always take priority.',
|
||||
'admin.defaultSettings.saved': 'Default saved',
|
||||
'admin.defaultSettings.reset': 'Reset to built-in default',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'reset',
|
||||
'admin.tabs.templates': 'Packing Templates',
|
||||
'admin.packingTemplates.title': 'Packing Templates',
|
||||
'admin.packingTemplates.subtitle': 'Create reusable packing lists for your trips',
|
||||
@@ -1060,6 +1074,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.meta.platform': 'Platform',
|
||||
'reservations.meta.seat': 'Seat',
|
||||
'reservations.meta.checkIn': 'Check-in',
|
||||
'reservations.meta.checkInUntil': 'Check-in until',
|
||||
'reservations.meta.checkOut': 'Check-out',
|
||||
'reservations.meta.linkAccommodation': 'Accommodation',
|
||||
'reservations.meta.pickAccommodation': 'Link to accommodation',
|
||||
@@ -1544,6 +1559,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'day.noPlacesForHotel': 'Add places to your trip first',
|
||||
'day.allDays': 'All',
|
||||
'day.checkIn': 'Check-in',
|
||||
'day.checkInUntil': 'Until',
|
||||
'day.checkOut': 'Check-out',
|
||||
'day.confirmation': 'Confirmation',
|
||||
'day.editAccommodation': 'Edit accommodation',
|
||||
@@ -1942,6 +1958,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.journeyTab': 'Journey',
|
||||
'journey.detail.journeyStats': 'Journey Stats',
|
||||
'journey.detail.syncedTrips': 'Synced Trips',
|
||||
'journey.detail.noTripsLinked': 'No trips linked yet',
|
||||
@@ -2206,6 +2223,51 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Search locations, resolve map URLs, and reverse geocode coordinates',
|
||||
'oauth.scope.weather:read.label': 'Weather forecasts',
|
||||
'oauth.scope.weather:read.description': 'Fetch weather forecasts for trip locations and dates',
|
||||
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'Photos have moved in 3.0',
|
||||
'system_notice.v3_photos.body': '**Photos** in the Trip Planner have been removed. Your photos are safe — TREK never modified your Immich or Synology library.\n\nPhotos now live in the **Journey** addon. Journey is optional — if it is not yet available, ask your admin to enable it under Admin → Addons.',
|
||||
'system_notice.v3_journey.title': 'Meet Journey — travel journal',
|
||||
'system_notice.v3_journey.body': 'Document your trips as rich travel stories with timelines, photo galleries, and interactive maps.',
|
||||
'system_notice.v3_journey.cta_label': 'Open Journey',
|
||||
'system_notice.v3_journey.highlight_timeline': 'Day-by-day timeline & gallery',
|
||||
'system_notice.v3_journey.highlight_photos': 'Import from Immich or Synology',
|
||||
'system_notice.v3_journey.highlight_share': 'Share publicly — no login needed',
|
||||
'system_notice.v3_journey.highlight_export': 'Export as a PDF photo book',
|
||||
'system_notice.v3_features.title': 'More highlights in 3.0',
|
||||
'system_notice.v3_features.body': 'A few more things worth knowing about this release.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'Mobile-first dashboard redesign',
|
||||
'system_notice.v3_features.highlight_offline': 'Full offline mode as a PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Real-time place search autocomplete',
|
||||
'system_notice.v3_features.highlight_import': 'Import places from KMZ/KML files',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: OAuth 2.1 upgrade',
|
||||
'system_notice.v3_mcp.body': 'The MCP integration has been fully overhauled. OAuth 2.1 is now the recommended auth method. Legacy static tokens (trek_\u2026) are deprecated and will be removed in a future release.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 recommended (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 fine-grained permission scopes',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Static trek_ tokens deprecated',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Expanded toolset & prompts',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'A personal note from me',
|
||||
'system_notice.v3_thankyou.body': 'Before you go — I want to take a moment.\n\nTREK started as a side project I built for my own trips. I never imagined it would grow into something that 4,000 of you now trust to plan your adventures. Every star, every issue, every feature request — I read them all, and they keep me going through late nights between a full-time job and university.\n\nI want you to know: TREK will always be open source, always self-hosted, always yours. No tracking, no subscriptions, no strings attached. Just a tool built by someone who loves traveling as much as you do.\n\nSpecial thanks to [jubnl](https://github.com/jubnl) — you have become an incredible collaborator. So much of what makes 3.0 great carries your fingerprints. Thank you for believing in this project when it was still rough around the edges.\n\nAnd to every single one of you who filed a bug, translated a string, shared TREK with a friend, or simply used it to plan a trip — **thank you**. You are the reason this exists.\n\nHere\'s to many more adventures together.\n\n— Maurice\n\n---\n\n[Join the community on Discord](https://discord.gg/7Q6M6jDwzf)\n\nIf TREK makes your travels better, a [small coffee](https://ko-fi.com/mauriceboe) always keeps the lights on.',
|
||||
|
||||
// System notices — onboarding
|
||||
'system_notice.welcome_v1.title': 'Welcome to TREK',
|
||||
'system_notice.welcome_v1.body': 'Your all-in-one travel planner. Build itineraries, share trips with friends, and stay organized — online or offline.',
|
||||
'system_notice.welcome_v1.cta_label': 'Plan a trip',
|
||||
'system_notice.welcome_v1.hero_alt': 'A scenic travel destination with TREK planning UI overlay',
|
||||
'system_notice.welcome_v1.highlight_plan': 'Day-by-day itineraries for any trip',
|
||||
'system_notice.welcome_v1.highlight_share': 'Collaborate with travel partners',
|
||||
'system_notice.welcome_v1.highlight_offline': 'Works offline on mobile',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'Previous notice',
|
||||
'system_notice.pager.next': 'Next notice',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'Go to notice {n}',
|
||||
'system_notice.pager.position': 'Notice {current} of {total}',
|
||||
}
|
||||
|
||||
export default en
|
||||
|
||||
@@ -543,7 +543,21 @@ const es: Record<string, string> = {
|
||||
|
||||
'admin.bagTracking.title': 'Seguimiento de equipaje',
|
||||
'admin.bagTracking.subtitle': 'Activar peso y asignación de equipaje para artículos de la lista',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
'admin.collab.chat.subtitle': 'Mensajería en tiempo real para la colaboración',
|
||||
'admin.collab.notes.title': 'Notas',
|
||||
'admin.collab.notes.subtitle': 'Notas y documentos compartidos',
|
||||
'admin.collab.polls.title': 'Encuestas',
|
||||
'admin.collab.polls.subtitle': 'Encuestas y votaciones grupales',
|
||||
'admin.collab.whatsnext.title': 'Qué sigue',
|
||||
'admin.collab.whatsnext.subtitle': 'Sugerencias de actividades y próximos pasos',
|
||||
'admin.tabs.config': 'Personalización',
|
||||
'admin.tabs.defaults': 'Valores predeterminados',
|
||||
'admin.defaultSettings.title': 'Configuración predeterminada de usuarios',
|
||||
'admin.defaultSettings.description': 'Establece valores predeterminados para toda la instancia. Los usuarios que no hayan cambiado una opción verán estos valores. Sus propios cambios siempre tienen prioridad.',
|
||||
'admin.defaultSettings.saved': 'Predeterminado guardado',
|
||||
'admin.defaultSettings.reset': 'Restaurar al valor predeterminado integrado',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'restaurar',
|
||||
'admin.tabs.templates': 'Plantillas de equipaje',
|
||||
'admin.packingTemplates.title': 'Plantillas de equipaje',
|
||||
'admin.packingTemplates.subtitle': 'Crear listas de equipaje reutilizables para tus viajes',
|
||||
@@ -1440,6 +1454,7 @@ const es: Record<string, string> = {
|
||||
'day.noPlacesForHotel': 'Añade primero lugares al viaje',
|
||||
'day.allDays': 'Todos',
|
||||
'day.checkIn': 'Registro de entrada',
|
||||
'day.checkInUntil': 'Hasta',
|
||||
'day.checkOut': 'Registro de salida',
|
||||
'day.confirmation': 'Confirmación',
|
||||
'day.editAccommodation': 'Editar alojamiento',
|
||||
@@ -1607,6 +1622,7 @@ const es: Record<string, string> = {
|
||||
'reservations.meta.platform': 'Andén',
|
||||
'reservations.meta.seat': 'Asiento',
|
||||
'reservations.meta.checkIn': 'Registro de entrada',
|
||||
'reservations.meta.checkInUntil': 'Check-in hasta',
|
||||
'reservations.meta.checkOut': 'Registro de salida',
|
||||
'reservations.meta.linkAccommodation': 'Alojamiento',
|
||||
'reservations.meta.pickAccommodation': 'Vincular con alojamiento',
|
||||
@@ -2172,6 +2188,50 @@ const es: Record<string, string> = {
|
||||
'oauth.scope.geo:read.description': 'Buscar lugares, resolver URLs de mapa y geocodificar coordenadas',
|
||||
'oauth.scope.weather:read.label': 'Previsiones meteorológicas',
|
||||
'oauth.scope.weather:read.description': 'Obtener previsiones meteorológicas para lugares y fechas del viaje',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Bienvenido a TREK',
|
||||
'system_notice.welcome_v1.body': 'Tu planificador de viajes todo en uno. Crea itinerarios, comparte viajes con amigos y mantente organizado, online o sin conexión.',
|
||||
'system_notice.welcome_v1.cta_label': 'Planificar un viaje',
|
||||
'system_notice.welcome_v1.hero_alt': 'Destino de viaje pintoresco con la interfaz de TREK',
|
||||
'system_notice.welcome_v1.highlight_plan': 'Itinerarios día a día para cualquier viaje',
|
||||
'system_notice.welcome_v1.highlight_share': 'Colabora con tus compañeros de viaje',
|
||||
'system_notice.welcome_v1.highlight_offline': 'Funciona sin conexión en móvil',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'Aviso anterior',
|
||||
'system_notice.pager.next': 'Siguiente aviso',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'Ir al aviso {n}',
|
||||
'system_notice.pager.position': 'Aviso {current} de {total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'Las fotos se han movido en 3.0',
|
||||
'system_notice.v3_photos.body': '**Fotos** en el Planificador de Viajes han sido eliminadas. Tus fotos están a salvo — TREK nunca modificó tu biblioteca de Immich o Synology.\n\nLas fotos ahora viven en el addon **Journey**. Journey es opcional — si aún no está disponible, pide a tu admin que lo active en Admin → Complementos.',
|
||||
'system_notice.v3_journey.title': 'Conoce Journey — diario de viaje',
|
||||
'system_notice.v3_journey.body': 'Documenta tus viajes como historias enriquecidas con cronologías, galerías de fotos y mapas interactivos.',
|
||||
'system_notice.v3_journey.cta_label': 'Abrir Journey',
|
||||
'system_notice.v3_journey.highlight_timeline': 'Cronología y galería por día',
|
||||
'system_notice.v3_journey.highlight_photos': 'Importar desde Immich o Synology',
|
||||
'system_notice.v3_journey.highlight_share': 'Compartir públicamente — sin inicio de sesión',
|
||||
'system_notice.v3_journey.highlight_export': 'Exportar como libro de fotos PDF',
|
||||
'system_notice.v3_features.title': 'Más novedades en 3.0',
|
||||
'system_notice.v3_features.body': 'Otras cosas que vale la pena conocer de esta versión.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'Rediseño del panel mobile-first',
|
||||
'system_notice.v3_features.highlight_offline': 'Modo sin conexión completo como PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Autocompletado de lugares en tiempo real',
|
||||
'system_notice.v3_features.highlight_import': 'Importar lugares desde archivos KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: actualización OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'La integración MCP ha sido completamente renovada. OAuth 2.1 es ahora el método de autenticación recomendado. Los tokens estáticos (trek_…) están obsoletos y se eliminarán en una versión futura.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 recomendado (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 ámbitos de permisos granulares',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Tokens estáticos trek_ obsoletos',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Herramientas y prompts ampliados',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Una nota personal de mi parte',
|
||||
'system_notice.v3_thankyou.body': 'Antes de seguir — quiero tomarme un momento.\n\nTREK empezó como un proyecto personal que construí para mis propios viajes. Nunca imaginé que crecería hasta convertirse en algo en lo que 4.000 de vosotros confían para planificar sus aventuras. Cada estrella, cada issue, cada solicitud de funcionalidad — los leo todos, y son lo que me mantiene en pie durante las noches largas entre un trabajo a jornada completa y la universidad.\n\nQuiero que sepáis: TREK siempre será open source, siempre self-hosted, siempre vuestro. Sin rastreo, sin suscripciones, sin letra pequeña. Solo una herramienta hecha por alguien que ama viajar tanto como vosotros.\n\nUn agradecimiento especial a [jubnl](https://github.com/jubnl) — te has convertido en un colaborador increíble. Mucho de lo que hace grande la versión 3.0 lleva tu huella. Gracias por creer en este proyecto cuando todavía era un borrador.\n\nY a cada uno de vosotros que reportó un bug, tradujo un texto, compartió TREK con un amigo o simplemente lo usó para planificar un viaje — **gracias**. Vosotros sois la razón de que esto exista.\n\nPor muchas más aventuras juntos.\n\n— Maurice\n\n---\n\n[Únete a la comunidad en Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK mejora tus viajes, un [pequeño café](https://ko-fi.com/mauriceboe) siempre mantiene las luces encendidas.',
|
||||
}
|
||||
|
||||
export default es
|
||||
|
||||
@@ -547,7 +547,21 @@ const fr: Record<string, string> = {
|
||||
|
||||
'admin.bagTracking.title': 'Suivi des bagages',
|
||||
'admin.bagTracking.subtitle': 'Activer le poids et l\'attribution de bagages pour les articles',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
'admin.collab.chat.subtitle': 'Messagerie en temps réel pour la collaboration',
|
||||
'admin.collab.notes.title': 'Notes',
|
||||
'admin.collab.notes.subtitle': 'Notes et documents partagés',
|
||||
'admin.collab.polls.title': 'Sondages',
|
||||
'admin.collab.polls.subtitle': 'Sondages et votes de groupe',
|
||||
'admin.collab.whatsnext.title': 'Et ensuite',
|
||||
'admin.collab.whatsnext.subtitle': "Suggestions d'activités et prochaines étapes",
|
||||
'admin.tabs.config': 'Personnalisation',
|
||||
'admin.tabs.defaults': 'Valeurs par défaut',
|
||||
'admin.defaultSettings.title': 'Paramètres utilisateur par défaut',
|
||||
'admin.defaultSettings.description': "Définissez des valeurs par défaut pour toute l'instance. Les utilisateurs n'ayant pas modifié un paramètre verront ces valeurs. Leurs propres modifications ont toujours la priorité.",
|
||||
'admin.defaultSettings.saved': 'Valeur par défaut enregistrée',
|
||||
'admin.defaultSettings.reset': 'Réinitialiser à la valeur par défaut intégrée',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'réinitialiser',
|
||||
'admin.tabs.templates': 'Modèles de bagages',
|
||||
'admin.packingTemplates.title': 'Modèles de bagages',
|
||||
'admin.packingTemplates.subtitle': 'Créer des listes de bagages réutilisables pour vos voyages',
|
||||
@@ -1003,6 +1017,7 @@ const fr: Record<string, string> = {
|
||||
'reservations.meta.platform': 'Quai',
|
||||
'reservations.meta.seat': 'Place',
|
||||
'reservations.meta.checkIn': 'Arrivée',
|
||||
'reservations.meta.checkInUntil': "Check-in jusqu'à",
|
||||
'reservations.meta.checkOut': 'Départ',
|
||||
'reservations.meta.linkAccommodation': 'Hébergement',
|
||||
'reservations.meta.pickAccommodation': 'Lier à un hébergement',
|
||||
@@ -1487,6 +1502,7 @@ const fr: Record<string, string> = {
|
||||
'day.noPlacesForHotel': 'Ajoutez d\'abord des lieux à votre voyage',
|
||||
'day.allDays': 'Tous',
|
||||
'day.checkIn': 'Arrivée',
|
||||
'day.checkInUntil': "Jusqu'à",
|
||||
'day.checkOut': 'Départ',
|
||||
'day.confirmation': 'Confirmation',
|
||||
'day.editAccommodation': 'Modifier l\'hébergement',
|
||||
@@ -2166,6 +2182,50 @@ const fr: Record<string, string> = {
|
||||
'oauth.scope.geo:read.description': 'Chercher des lieux, résoudre des URL cartographiques et géocoder des coordonnées',
|
||||
'oauth.scope.weather:read.label': 'Prévisions météo',
|
||||
'oauth.scope.weather:read.description': 'Obtenir les prévisions météo pour les lieux et dates de voyage',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Bienvenue sur TREK',
|
||||
'system_notice.welcome_v1.body': 'Votre planificateur de voyage tout-en-un. Créez des itinéraires, partagez vos voyages et restez organisé — en ligne ou hors ligne.',
|
||||
'system_notice.welcome_v1.cta_label': 'Planifier un voyage',
|
||||
'system_notice.welcome_v1.hero_alt': 'Destination de voyage pittoresque avec l\'interface TREK',
|
||||
'system_notice.welcome_v1.highlight_plan': 'Itinéraires jour par jour',
|
||||
'system_notice.welcome_v1.highlight_share': 'Collaborez avec vos partenaires',
|
||||
'system_notice.welcome_v1.highlight_offline': 'Fonctionne hors ligne sur mobile',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'Avis précédent',
|
||||
'system_notice.pager.next': 'Avis suivant',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': "Aller à l'avis {n}",
|
||||
'system_notice.pager.position': 'Avis {current} sur {total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'Les photos ont bougé dans 3.0',
|
||||
'system_notice.v3_photos.body': "**Photos** dans le planificateur ont été supprimées. Tes photos sont en sécurité — TREK n'a jamais modifié ta bibliothèque Immich ou Synology.\n\nLes photos vivent désormais dans l'addon **Journey**. Journey est optionnel — s'il n'est pas encore disponible, demande à ton admin de l'activer dans Admin → Modules.",
|
||||
'system_notice.v3_journey.title': 'Découvrez Journey — journal de voyage',
|
||||
'system_notice.v3_journey.body': 'Documente tes voyages sous forme de récits enrichis avec chronologies, galeries photos et cartes interactives.',
|
||||
'system_notice.v3_journey.cta_label': 'Ouvrir Journey',
|
||||
'system_notice.v3_journey.highlight_timeline': 'Chronologie et galerie par jour',
|
||||
'system_notice.v3_journey.highlight_photos': 'Import depuis Immich ou Synology',
|
||||
'system_notice.v3_journey.highlight_share': 'Partage public — sans connexion requise',
|
||||
'system_notice.v3_journey.highlight_export': 'Export en livre photo PDF',
|
||||
'system_notice.v3_features.title': 'Plus de nouveautés en 3.0',
|
||||
'system_notice.v3_features.body': 'Quelques autres choses à savoir sur cette version.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'Tableau de bord repensé mobile-first',
|
||||
'system_notice.v3_features.highlight_offline': 'Mode hors ligne complet en PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Autocomplétion des lieux en temps réel',
|
||||
'system_notice.v3_features.highlight_import': 'Importer des lieux depuis KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP : mise à niveau OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': "L'intégration MCP a été entièrement repensée. OAuth 2.1 est désormais la méthode d'authentification recommandée. Les tokens statiques (trek_\u2026) sont dépréciés et seront supprimés dans une future version.",
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 recommandé (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 scopes de permissions granulaires',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Tokens statiques trek_ dépréciés',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Outils et prompts étendus',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Un mot personnel de ma part',
|
||||
'system_notice.v3_thankyou.body': 'Avant de continuer — je veux prendre un instant.\n\nTREK a commencé comme un projet perso que j\'ai construit pour mes propres voyages. Je n\'aurais jamais imaginé qu\'il grandirait au point que 4 000 d\'entre vous lui fassent confiance pour planifier vos aventures. Chaque étoile, chaque issue, chaque demande de fonctionnalité — je les lis toutes, et ce sont elles qui me font tenir pendant les nuits blanches entre un travail à temps plein et l\'université.\n\nJe veux que vous sachiez : TREK sera toujours open source, toujours auto-hébergé, toujours à vous. Pas de tracking, pas d\'abonnements, pas de conditions cachées. Juste un outil construit par quelqu\'un qui aime voyager autant que vous.\n\nUn merci tout particulier à [jubnl](https://github.com/jubnl) — tu es devenu un collaborateur incroyable. Une grande partie de ce qui rend la 3.0 géniale porte ton empreinte. Merci d\'avoir cru en ce projet quand il était encore brut.\n\nEt à chacun d\'entre vous qui a signalé un bug, traduit une chaîne, partagé TREK avec un ami ou simplement l\'a utilisé pour planifier un voyage — **merci**. Vous êtes la raison pour laquelle tout ceci existe.\n\nÀ de nombreuses autres aventures ensemble.\n\n— Maurice\n\n---\n\n[Rejoins la communauté sur Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK rend tes voyages meilleurs, un [petit café](https://ko-fi.com/mauriceboe) aide toujours à garder les lumières allumées.',
|
||||
}
|
||||
|
||||
export default fr
|
||||
|
||||
@@ -548,7 +548,21 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Csomagolási sablonok és poggyászkövetés
|
||||
'admin.bagTracking.title': 'Poggyászkövetés',
|
||||
'admin.bagTracking.subtitle': 'Súly- és táskahozzárendelés engedélyezése csomagolási tételeknél',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
'admin.collab.chat.subtitle': 'Valós idejű üzenetküldés az együttműködéshez',
|
||||
'admin.collab.notes.title': 'Jegyzetek',
|
||||
'admin.collab.notes.subtitle': 'Megosztott jegyzetek és dokumentumok',
|
||||
'admin.collab.polls.title': 'Szavazások',
|
||||
'admin.collab.polls.subtitle': 'Csoportos szavazások',
|
||||
'admin.collab.whatsnext.title': 'Mi következik',
|
||||
'admin.collab.whatsnext.subtitle': 'Tevékenységjavaslatok és következő lépések',
|
||||
'admin.tabs.config': 'Személyre szabás',
|
||||
'admin.tabs.defaults': 'Alapértelmezett beállítások',
|
||||
'admin.defaultSettings.title': 'Alapértelmezett felhasználói beállítások',
|
||||
'admin.defaultSettings.description': 'Állítson be alapértelmezett értékeket az egész példányra. Azok a felhasználók, akik nem módosítottak egy beállítást, ezeket az értékeket fogják látni. A saját módosításaik mindig elsőbbséget élveznek.',
|
||||
'admin.defaultSettings.saved': 'Alapértelmezett mentve',
|
||||
'admin.defaultSettings.reset': 'Visszaállítás a beépített alapértelmezésre',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'visszaállítás',
|
||||
'admin.tabs.templates': 'Csomagolási sablonok',
|
||||
'admin.packingTemplates.title': 'Csomagolási sablonok',
|
||||
'admin.packingTemplates.subtitle': 'Újrafelhasználható csomagolási listák létrehozása utazásaidhoz',
|
||||
@@ -1005,6 +1019,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.meta.platform': 'Vágány',
|
||||
'reservations.meta.seat': 'Ülés',
|
||||
'reservations.meta.checkIn': 'Bejelentkezés',
|
||||
'reservations.meta.checkInUntil': 'Bejelentkezés eddig',
|
||||
'reservations.meta.checkOut': 'Kijelentkezés',
|
||||
'reservations.meta.linkAccommodation': 'Szállás',
|
||||
'reservations.meta.pickAccommodation': 'Szállás hozzárendelése',
|
||||
@@ -1488,6 +1503,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'day.noPlacesForHotel': 'Először adj hozzá helyeket az utazásodhoz',
|
||||
'day.allDays': 'Összes',
|
||||
'day.checkIn': 'Bejelentkezés',
|
||||
'day.checkInUntil': 'Eddig',
|
||||
'day.checkOut': 'Kijelentkezés',
|
||||
'day.confirmation': 'Visszaigazolás',
|
||||
'day.editAccommodation': 'Szállás szerkesztése',
|
||||
@@ -2167,6 +2183,50 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Helyek keresése, térkép URL-ek feloldása és koordináták fordított geokódolása',
|
||||
'oauth.scope.weather:read.label': 'Időjárás-előrejelzések',
|
||||
'oauth.scope.weather:read.description': 'Időjárás-előrejelzések lekérése az utazási helyszínekre és dátumokra',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Üdvözöl a TREK',
|
||||
'system_notice.welcome_v1.body': 'Az összes az egyben utazástervező. Készítsen útvonalakat, ossza meg az utakat barátaival, és maradjon szervezett — online és offline.',
|
||||
'system_notice.welcome_v1.cta_label': 'Utazás tervezése',
|
||||
'system_notice.welcome_v1.hero_alt': 'Festői úticél TREK tervező felülettel',
|
||||
'system_notice.welcome_v1.highlight_plan': 'Napi útvonalak minden utazáshoz',
|
||||
'system_notice.welcome_v1.highlight_share': 'Együttműködés utazótársakkal',
|
||||
'system_notice.welcome_v1.highlight_offline': 'Mobilon offline is működik',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'Előző értesítés',
|
||||
'system_notice.pager.next': 'Következő értesítés',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': '{n}. értesítésre ugrás',
|
||||
'system_notice.pager.position': '{current}/{total}. értesítés',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'A fotók helye megváltozott 3.0-ban',
|
||||
'system_notice.v3_photos.body': 'Az útiterv-tervező **Fényképek** lapja eltávolításra került. Fényképeid biztonságban vannak — TREK soha nem módosította Immich vagy Synology könyvtáradat.\n\nA fényképek mostantól a **Journey** bővítményben élnek. A Journey opcionális — ha még nem elérhető, kérd meg a rendszergazdát, hogy engedélyezze Admin → Bővítmények alatt.',
|
||||
'system_notice.v3_journey.title': 'Ismerje meg a Journey-t — útinnapló',
|
||||
'system_notice.v3_journey.body': 'Dokumentáld utazazsaid gazdag történetekként idővonalakkal, fotgáriákkal és interaktív térképekkel.',
|
||||
'system_notice.v3_journey.cta_label': 'Journey megnyitása',
|
||||
'system_notice.v3_journey.highlight_timeline': 'Napi idővonal és galéria',
|
||||
'system_notice.v3_journey.highlight_photos': 'Import Immich-ből vagy Synology-ból',
|
||||
'system_notice.v3_journey.highlight_share': 'Nyilvános megosztás — bejelentkezés nélkül',
|
||||
'system_notice.v3_journey.highlight_export': 'Exportálás PDF fotkönyvként',
|
||||
'system_notice.v3_features.title': 'További újdonságok a 3.0-ban',
|
||||
'system_notice.v3_features.body': 'Néhány további dolog, amit érdemes tudni erről a kiadásról.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'Mobile-first irmütébla újratervezve',
|
||||
'system_notice.v3_features.highlight_offline': 'Teljes offline mód PWA-ként',
|
||||
'system_notice.v3_features.highlight_search': 'Valós idejű helykeresés-kiegészítés',
|
||||
'system_notice.v3_features.highlight_import': 'Helyek importálása KMZ/KML fájlokból',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: OAuth 2.1 frissítés',
|
||||
'system_notice.v3_mcp.body': 'Az MCP integráció teljesen megújult. Az OAuth 2.1 mostantól az ajánlott hitelesítési módszer. A statikus tokenek (trek_…) elavultak és egy jövőbeli kiadásban eltávolításra kerülnek.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 ajánlott (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 részletes engedélyezési hatókör',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Statikus trek_ tokenek elavultak',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Bővített eszközkészlet és promptok',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Egy személyes gondolat tőlem',
|
||||
'system_notice.v3_thankyou.body': 'Mielőtt továbbmennél — szeretnék egy pillanatra megállni.\n\nA TREK egy hobbiprojektként indult, amit a saját utazásaimhoz építettem. Sosem gondoltam volna, hogy valami olyanná nő, amire 4000-en bízzátok a kalandjaitok tervezését. Minden csillagot, minden issue-t, minden funkciókérést — mindet elolvasom, és ezek tartanak életben a késő éjszakákon a teljes állás és az egyetem között.\n\nSzeretnétek, ha tudnátok: a TREK mindig nyílt forráskódú marad, mindig self-hosted, mindig a tiétek. Nincs nyomkövetés, nincs előfizetés, nincsenek rejtett feltételek. Csak egy eszköz, amit valaki épített, aki ugyanúgy szereti az utazást, mint ti.\n\nKülönleges köszönet [jubnl](https://github.com/jubnl)-nek — hihetetlen társsá váltál. A 3.0 nagyszerűségének nagy része a te kézjegyedet viseli. Köszönöm, hogy hittél ebben a projektben, amikor még nyers volt.\n\nÉs mindannyiótoknak, akik hibát jelentettetek, szöveget fordítottatok, megosztottátok a TREK-et egy baráttal, vagy egyszerűen csak egy utazást terveztetek vele — **köszönöm**. Ti vagytok az ok, amiért ez létezik.\n\nSok további közös kalandért.\n\n— Maurice\n\n---\n\n[Csatlakozz a közösséghez a Discordon](https://discord.gg/7Q6M6jDwzf)\n\nHa a TREK jobbá teszi az utazásaidat, egy [kis kávé](https://ko-fi.com/mauriceboe) mindig segít, hogy égve maradjanak a fények.',
|
||||
}
|
||||
|
||||
export default hu
|
||||
|
||||
@@ -608,7 +608,21 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Packing Templates & Bag Tracking
|
||||
'admin.bagTracking.title': 'Pelacak Tas',
|
||||
'admin.bagTracking.subtitle': 'Aktifkan berat dan penugasan tas untuk item packing',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
'admin.collab.chat.subtitle': 'Pesan real-time untuk kolaborasi',
|
||||
'admin.collab.notes.title': 'Catatan',
|
||||
'admin.collab.notes.subtitle': 'Catatan dan dokumen bersama',
|
||||
'admin.collab.polls.title': 'Jajak Pendapat',
|
||||
'admin.collab.polls.subtitle': 'Jajak pendapat dan voting grup',
|
||||
'admin.collab.whatsnext.title': 'Selanjutnya',
|
||||
'admin.collab.whatsnext.subtitle': 'Saran aktivitas dan langkah selanjutnya',
|
||||
'admin.tabs.config': 'Personalisasi',
|
||||
'admin.tabs.defaults': 'Pengaturan Default Pengguna',
|
||||
'admin.defaultSettings.title': 'Pengaturan Default Pengguna',
|
||||
'admin.defaultSettings.description': 'Tetapkan nilai default untuk seluruh instance. Pengguna yang belum mengubah pengaturan akan melihat nilai-nilai ini. Perubahan mereka sendiri selalu diprioritaskan.',
|
||||
'admin.defaultSettings.saved': 'Default disimpan',
|
||||
'admin.defaultSettings.reset': 'Atur ulang ke default bawaan',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'atur ulang',
|
||||
'admin.tabs.templates': 'Template Packing',
|
||||
'admin.packingTemplates.title': 'Template Packing',
|
||||
'admin.packingTemplates.subtitle': 'Buat daftar packing yang bisa digunakan ulang untuk perjalananmu',
|
||||
@@ -1060,6 +1074,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.meta.platform': 'Peron',
|
||||
'reservations.meta.seat': 'Kursi',
|
||||
'reservations.meta.checkIn': 'Check-in',
|
||||
'reservations.meta.checkInUntil': 'Check-in sampai',
|
||||
'reservations.meta.checkOut': 'Check-out',
|
||||
'reservations.meta.linkAccommodation': 'Akomodasi',
|
||||
'reservations.meta.pickAccommodation': 'Hubungkan ke akomodasi',
|
||||
@@ -1544,6 +1559,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'day.noPlacesForHotel': 'Tambahkan tempat ke perjalananmu terlebih dahulu',
|
||||
'day.allDays': 'Semua',
|
||||
'day.checkIn': 'Check-in',
|
||||
'day.checkInUntil': 'Sampai',
|
||||
'day.checkOut': 'Check-out',
|
||||
'day.confirmation': 'Konfirmasi',
|
||||
'day.editAccommodation': 'Edit akomodasi',
|
||||
@@ -2208,6 +2224,50 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.weather:read.description': 'Ambil prakiraan cuaca untuk lokasi dan tanggal perjalanan',
|
||||
|
||||
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Selamat datang di TREK',
|
||||
'system_notice.welcome_v1.body': 'Perencana perjalanan lengkap Anda. Buat itinerari, bagikan perjalanan dengan teman, dan tetap terorganisir — online maupun offline.',
|
||||
'system_notice.welcome_v1.cta_label': 'Rencanakan perjalanan',
|
||||
'system_notice.welcome_v1.hero_alt': 'Destinasi wisata indah dengan antarmuka TREK',
|
||||
'system_notice.welcome_v1.highlight_plan': 'Itinerari harian untuk setiap perjalanan',
|
||||
'system_notice.welcome_v1.highlight_share': 'Berkolaborasi dengan teman perjalanan',
|
||||
'system_notice.welcome_v1.highlight_offline': 'Bekerja offline di ponsel',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'Pemberitahuan sebelumnya',
|
||||
'system_notice.pager.next': 'Pemberitahuan berikutnya',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'Pergi ke pemberitahuan {n}',
|
||||
'system_notice.pager.position': 'Pemberitahuan {current} dari {total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'Foto dipindahkan di 3.0',
|
||||
'system_notice.v3_photos.body': '**Foto** di Perencana Perjalanan telah dihapus. Foto Anda aman — TREK tidak pernah mengubah perpustakaan Immich atau Synology Anda.\n\nFoto kini ada di addon **Journey**. Journey bersifat opsional — jika belum tersedia, minta admin untuk mengaktifkannya di Admin → Addon.',
|
||||
'system_notice.v3_journey.title': 'Kenali Journey — jurnal perjalanan',
|
||||
'system_notice.v3_journey.body': 'Dokumentasikan perjalanan Anda sebagai cerita hidup dengan linimasa, galeri foto, dan peta interaktif.',
|
||||
'system_notice.v3_journey.cta_label': 'Buka Journey',
|
||||
'system_notice.v3_journey.highlight_timeline': 'Linimasa & galeri',
|
||||
'system_notice.v3_journey.highlight_photos': 'Impor dari Immich atau Synology',
|
||||
'system_notice.v3_journey.highlight_share': 'Bagikan secara publik — tanpa login',
|
||||
'system_notice.v3_journey.highlight_export': 'Ekspor sebagai buku foto PDF',
|
||||
'system_notice.v3_features.title': 'Sorotan lain di 3.0',
|
||||
'system_notice.v3_features.body': 'Beberapa pembaruan lain dalam rilis ini.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'Desain ulang dashboard mobile-first',
|
||||
'system_notice.v3_features.highlight_offline': 'Mode offline penuh sebagai PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Pelengkapan otomatis tempat secara real-time',
|
||||
'system_notice.v3_features.highlight_import': 'Impor tempat dari file KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: pembaruan OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'Integrasi MCP telah sepenuhnya diperbarui. OAuth 2.1 kini menjadi metode autentikasi yang direkomendasikan. Token statis (trek_…) sudah usang dan akan dihapus pada versi mendatang.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 direkomendasikan (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 cakupan izin yang terperinci',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Token statis trek_ sudah usang',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Perangkat dan prompt yang diperluas',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Catatan pribadi dari saya',
|
||||
'system_notice.v3_thankyou.body': 'Sebelum kamu lanjut — saya ingin berhenti sejenak.\n\nTREK dimulai sebagai proyek sampingan yang saya buat untuk perjalanan saya sendiri. Saya tidak pernah membayangkan ia akan tumbuh menjadi sesuatu yang dipercaya oleh 4.000 dari kalian untuk merencanakan petualangan. Setiap bintang, setiap issue, setiap permintaan fitur — saya membaca semuanya, dan itulah yang membuat saya terus bertahan di malam-malam larut antara pekerjaan penuh waktu dan kuliah.\n\nSaya ingin kalian tahu: TREK akan selalu open source, selalu self-hosted, selalu milik kalian. Tanpa pelacakan, tanpa langganan, tanpa syarat tersembunyi. Hanya sebuah alat yang dibuat oleh seseorang yang mencintai traveling sama seperti kalian.\n\nTerima kasih khusus untuk [jubnl](https://github.com/jubnl) — kamu telah menjadi kolaborator yang luar biasa. Begitu banyak hal yang membuat versi 3.0 hebat memiliki jejakmu. Terima kasih telah percaya pada proyek ini ketika masih kasar.\n\nDan untuk setiap dari kalian yang melaporkan bug, menerjemahkan string, membagikan TREK kepada teman, atau sekadar menggunakannya untuk merencanakan perjalanan — **terima kasih**. Kalianlah alasan semua ini ada.\n\nUntuk lebih banyak petualangan bersama.\n\n— Maurice\n\n---\n\n[Bergabunglah dengan komunitas di Discord](https://discord.gg/7Q6M6jDwzf)\n\nJika TREK membuat perjalananmu lebih baik, [secangkir kopi kecil](https://ko-fi.com/mauriceboe) selalu membantu menjaga lampu tetap menyala.',
|
||||
};
|
||||
|
||||
export default id;
|
||||
|
||||
@@ -547,7 +547,21 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Packing Templates & Bag Tracking
|
||||
'admin.bagTracking.title': 'Tracciamento valigia',
|
||||
'admin.bagTracking.subtitle': 'Abilita il peso e l\'assegnazione della valigia per gli elementi della lista valigia',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
'admin.collab.chat.subtitle': 'Messaggistica in tempo reale per la collaborazione',
|
||||
'admin.collab.notes.title': 'Note',
|
||||
'admin.collab.notes.subtitle': 'Note e documenti condivisi',
|
||||
'admin.collab.polls.title': 'Sondaggi',
|
||||
'admin.collab.polls.subtitle': 'Sondaggi e votazioni di gruppo',
|
||||
'admin.collab.whatsnext.title': 'Prossimi passi',
|
||||
'admin.collab.whatsnext.subtitle': 'Suggerimenti attività e prossimi passi',
|
||||
'admin.tabs.config': 'Personalizzazione',
|
||||
'admin.tabs.defaults': 'Impostazioni predefinite',
|
||||
'admin.defaultSettings.title': 'Impostazioni predefinite utente',
|
||||
'admin.defaultSettings.description': "Imposta i valori predefiniti per l'intera istanza. Gli utenti che non hanno modificato un'impostazione vedranno questi valori. Le loro modifiche hanno sempre la priorità.",
|
||||
'admin.defaultSettings.saved': 'Predefinito salvato',
|
||||
'admin.defaultSettings.reset': 'Ripristina il predefinito integrato',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'ripristina',
|
||||
'admin.tabs.templates': 'Modelli lista valigia',
|
||||
'admin.packingTemplates.title': 'Modelli lista valigia',
|
||||
'admin.packingTemplates.subtitle': 'Crea liste valigia riutilizzabili per i tuoi viaggi',
|
||||
@@ -1004,6 +1018,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.meta.platform': 'Binario',
|
||||
'reservations.meta.seat': 'Posto',
|
||||
'reservations.meta.checkIn': 'Check-in',
|
||||
'reservations.meta.checkInUntil': 'Check-in fino a',
|
||||
'reservations.meta.checkOut': 'Check-out',
|
||||
'reservations.meta.linkAccommodation': 'Alloggio',
|
||||
'reservations.meta.pickAccommodation': 'Collega a un alloggio',
|
||||
@@ -1488,6 +1503,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'day.noPlacesForHotel': 'Aggiungi prima i luoghi al tuo viaggio',
|
||||
'day.allDays': 'Tutti',
|
||||
'day.checkIn': 'Check-in',
|
||||
'day.checkInUntil': 'Fino a',
|
||||
'day.checkOut': 'Check-out',
|
||||
'day.confirmation': 'Conferma',
|
||||
'day.editAccommodation': 'Modifica alloggio',
|
||||
@@ -2167,6 +2183,50 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Cerca luoghi, risolvi URL mappa e geocodifica inversa coordinate',
|
||||
'oauth.scope.weather:read.label': 'Previsioni meteo',
|
||||
'oauth.scope.weather:read.description': 'Ottieni previsioni meteo per luoghi e date del viaggio',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Benvenuto su TREK',
|
||||
'system_notice.welcome_v1.body': 'Il tuo pianificatore di viaggi tutto in uno. Crea itinerari, condividi viaggi con gli amici e rimani organizzato — online e offline.',
|
||||
'system_notice.welcome_v1.cta_label': 'Pianifica un viaggio',
|
||||
'system_notice.welcome_v1.hero_alt': 'Destinazione di viaggio panoramica con l\'interfaccia TREK',
|
||||
'system_notice.welcome_v1.highlight_plan': 'Itinerari giorno per giorno',
|
||||
'system_notice.welcome_v1.highlight_share': 'Collabora con i tuoi compagni di viaggio',
|
||||
'system_notice.welcome_v1.highlight_offline': 'Funziona offline su mobile',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'Avviso precedente',
|
||||
'system_notice.pager.next': 'Avviso successivo',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': "Vai all'avviso {n}",
|
||||
'system_notice.pager.position': 'Avviso {current} di {total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'Le foto sono spostate nella 3.0',
|
||||
'system_notice.v3_photos.body': '**Foto** nel Pianificatore di Viaggio sono state rimosse. Le tue foto sono al sicuro — TREK non ha mai modificato la tua libreria Immich o Synology.\n\nLe foto ora si trovano nel componente aggiuntivo **Journey**. Journey è opzionale — se non è ancora disponibile, chiedi al tuo admin di abilitarlo in Admin → Addon.',
|
||||
'system_notice.v3_journey.title': 'Scopri Journey — diario di viaggio',
|
||||
'system_notice.v3_journey.body': 'Documenta i tuoi viaggi come storie ricche con cronologie, gallerie fotografiche e mappe interattive.',
|
||||
'system_notice.v3_journey.cta_label': 'Apri Journey',
|
||||
'system_notice.v3_journey.highlight_timeline': 'Cronologia e galleria giornaliera',
|
||||
'system_notice.v3_journey.highlight_photos': 'Importa da Immich o Synology',
|
||||
'system_notice.v3_journey.highlight_share': 'Condividi pubblicamente — senza accesso',
|
||||
'system_notice.v3_journey.highlight_export': 'Esporta come libro fotografico PDF',
|
||||
'system_notice.v3_features.title': 'Altri punti salienti nel 3.0',
|
||||
'system_notice.v3_features.body': 'Altre novità da conoscere in questa versione.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'Dashboard ridisegnata mobile-first',
|
||||
'system_notice.v3_features.highlight_offline': 'Modalità offline completa come PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Completamento automatico luoghi in tempo reale',
|
||||
'system_notice.v3_features.highlight_import': 'Importa luoghi da file KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: aggiornamento OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': "L'integrazione MCP è stata completamente rinnovata. OAuth 2.1 è ora il metodo di autenticazione consigliato. I token statici (trek_\u2026) sono deprecati e verranno rimossi in una versione futura.",
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 consigliato (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 scope di autorizzazione granulari',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Token statici trek_ deprecati',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Strumenti e prompt estesi',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Una nota personale da parte mia',
|
||||
'system_notice.v3_thankyou.body': 'Prima di andare avanti — voglio prendermi un momento.\n\nTREK è nato come un progetto secondario che ho costruito per i miei viaggi. Non avrei mai immaginato che sarebbe cresciuto fino a diventare qualcosa di cui 4.000 di voi si fidano per pianificare le proprie avventure. Ogni stella, ogni issue, ogni richiesta di funzionalità — le leggo tutte, e sono loro a tenermi in piedi nelle notti tarde tra un lavoro a tempo pieno e l\'università.\n\nVoglio che sappiate: TREK sarà sempre open source, sempre self-hosted, sempre vostro. Nessun tracciamento, nessun abbonamento, nessuna fregatura. Solo uno strumento creato da qualcuno che ama viaggiare tanto quanto voi.\n\nUn ringraziamento speciale a [jubnl](https://github.com/jubnl) — sei diventato un collaboratore incredibile. Molto di ciò che rende la 3.0 fantastica porta la tua impronta. Grazie per aver creduto in questo progetto quando era ancora acerbo.\n\nE a ognuno di voi che ha segnalato un bug, tradotto una stringa, condiviso TREK con un amico o semplicemente lo ha usato per pianificare un viaggio — **grazie**. Voi siete il motivo per cui tutto questo esiste.\n\nA molte altre avventure insieme.\n\n— Maurice\n\n---\n\n[Unisciti alla community su Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe TREK rende i tuoi viaggi migliori, un [piccolo caffè](https://ko-fi.com/mauriceboe) aiuta sempre a tenere le luci accese.',
|
||||
}
|
||||
|
||||
export default it
|
||||
|
||||
@@ -548,7 +548,21 @@ const nl: Record<string, string> = {
|
||||
|
||||
'admin.bagTracking.title': 'Bagagetracking',
|
||||
'admin.bagTracking.subtitle': 'Gewicht en bagagetoewijzing inschakelen voor paklijstitems',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
'admin.collab.chat.subtitle': 'Realtime berichten voor reissamenwerking',
|
||||
'admin.collab.notes.title': 'Notities',
|
||||
'admin.collab.notes.subtitle': 'Gedeelde notities en documenten',
|
||||
'admin.collab.polls.title': 'Peilingen',
|
||||
'admin.collab.polls.subtitle': 'Groepspeilingen en stemmen',
|
||||
'admin.collab.whatsnext.title': 'Wat nu',
|
||||
'admin.collab.whatsnext.subtitle': 'Activiteitssuggesties en volgende stappen',
|
||||
'admin.tabs.config': 'Personalisatie',
|
||||
'admin.tabs.defaults': 'Standaardinstellingen',
|
||||
'admin.defaultSettings.title': 'Standaard gebruikersinstellingen',
|
||||
'admin.defaultSettings.description': 'Stel instantiebrede standaardwaarden in. Gebruikers die een instelling niet hebben gewijzigd, zien deze waarden. Hun eigen wijzigingen hebben altijd voorrang.',
|
||||
'admin.defaultSettings.saved': 'Standaard opgeslagen',
|
||||
'admin.defaultSettings.reset': 'Terugzetten naar ingebouwde standaard',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'terugzetten',
|
||||
'admin.tabs.templates': 'Paksjablonen',
|
||||
'admin.packingTemplates.title': 'Paksjablonen',
|
||||
'admin.packingTemplates.subtitle': 'Herbruikbare paklijsten maken voor je reizen',
|
||||
@@ -1003,6 +1017,7 @@ const nl: Record<string, string> = {
|
||||
'reservations.meta.platform': 'Perron',
|
||||
'reservations.meta.seat': 'Stoel',
|
||||
'reservations.meta.checkIn': 'Inchecken',
|
||||
'reservations.meta.checkInUntil': 'Check-in tot',
|
||||
'reservations.meta.checkOut': 'Uitchecken',
|
||||
'reservations.meta.linkAccommodation': 'Accommodatie',
|
||||
'reservations.meta.pickAccommodation': 'Koppel aan accommodatie',
|
||||
@@ -1487,6 +1502,7 @@ const nl: Record<string, string> = {
|
||||
'day.noPlacesForHotel': 'Voeg eerst plaatsen toe aan je reis',
|
||||
'day.allDays': 'Alle',
|
||||
'day.checkIn': 'Inchecken',
|
||||
'day.checkInUntil': 'Tot',
|
||||
'day.checkOut': 'Uitchecken',
|
||||
'day.confirmation': 'Bevestiging',
|
||||
'day.editAccommodation': 'Accommodatie bewerken',
|
||||
@@ -2166,6 +2182,50 @@ const nl: Record<string, string> = {
|
||||
'oauth.scope.geo:read.description': 'Locaties zoeken, kaart-URL\'s oplossen en coördinaten omgekeerd geocoderen',
|
||||
'oauth.scope.weather:read.label': 'Weersverwachtingen',
|
||||
'oauth.scope.weather:read.description': 'Weersverwachtingen ophalen voor reislocaties en -datums',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Welkom bij TREK',
|
||||
'system_notice.welcome_v1.body': 'Jouw alles-in-één reisplanner. Maak reisschema\'s, deel trips met vrienden en blijf georganiseerd — online en offline.',
|
||||
'system_notice.welcome_v1.cta_label': 'Reis plannen',
|
||||
'system_notice.welcome_v1.hero_alt': 'Schilderachtige reisbestemming met TREK interface',
|
||||
'system_notice.welcome_v1.highlight_plan': 'Dag-voor-dag reisschema\'s',
|
||||
'system_notice.welcome_v1.highlight_share': 'Samenwerken met reisgezelschap',
|
||||
'system_notice.welcome_v1.highlight_offline': 'Werkt offline op mobiel',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'Vorige melding',
|
||||
'system_notice.pager.next': 'Volgende melding',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'Ga naar melding {n}',
|
||||
'system_notice.pager.position': 'Melding {current} van {total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': "Foto's zijn verplaatst in 3.0",
|
||||
'system_notice.v3_photos.body': "**Foto's** in de Reisplanner zijn verwijderd. Je foto's zijn veilig — TREK heeft je Immich- of Synology-bibliotheek nooit gewijzigd.\n\nFoto's leven nu in de **Journey**-addon. Journey is optioneel — als het nog niet beschikbaar is, vraag je admin het te activeren via Admin → Addons.",
|
||||
'system_notice.v3_journey.title': 'Maak kennis met Journey — reisdagboek',
|
||||
'system_notice.v3_journey.body': 'Documenteer je reizen als rijke verhalen met tijdlijnen, fotogalerijen en interactieve kaarten.',
|
||||
'system_notice.v3_journey.cta_label': 'Journey openen',
|
||||
'system_notice.v3_journey.highlight_timeline': 'Dag-voor-dag tijdlijn & galerij',
|
||||
'system_notice.v3_journey.highlight_photos': 'Importeer van Immich of Synology',
|
||||
'system_notice.v3_journey.highlight_share': 'Openbaar delen — geen login vereist',
|
||||
'system_notice.v3_journey.highlight_export': 'Exporteer als PDF-fotoboek',
|
||||
'system_notice.v3_features.title': 'Meer hoogtepunten in 3.0',
|
||||
'system_notice.v3_features.body': 'Nog een paar dingen die het weten waard zijn in deze release.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'Mobile-first dashboard herontwerp',
|
||||
'system_notice.v3_features.highlight_offline': 'Volledige offline modus als PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Realtime plaatsautocomplete',
|
||||
'system_notice.v3_features.highlight_import': 'Importeer plaatsen uit KMZ/KML-bestanden',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: OAuth 2.1-upgrade',
|
||||
'system_notice.v3_mcp.body': 'De MCP-integratie is volledig vernieuwd. OAuth 2.1 is nu de aanbevolen authenticatiemethode. Statische tokens (trek_…) zijn verouderd en worden verwijderd in een toekomstige versie.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 aanbevolen (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 gedetailleerde toestemmingsscopes',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Statische trek_-tokens verouderd',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Uitgebreide tools & prompts',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Een persoonlijk woord van mij',
|
||||
'system_notice.v3_thankyou.body': 'Voordat je verdergaat — ik wil even stilstaan.\n\nTREK begon als een zijproject dat ik bouwde voor mijn eigen reizen. Ik had nooit gedacht dat het zou uitgroeien tot iets waar 4.000 van jullie op vertrouwen om avonturen te plannen. Elke ster, elke issue, elk functieverzoek — ik lees ze allemaal, en ze houden me op de been tijdens de late avonden tussen een fulltime baan en de universiteit.\n\nIk wil dat jullie weten: TREK zal altijd open source zijn, altijd self-hosted, altijd van jullie. Geen tracking, geen abonnementen, geen addertjes. Gewoon een tool gebouwd door iemand die net zo veel van reizen houdt als jullie.\n\nSpeciale dank aan [jubnl](https://github.com/jubnl) — je bent een ongelooflijke medewerker geworden. Zo veel van wat 3.0 geweldig maakt draagt jouw vingerafdruk. Bedankt dat je in dit project geloofde toen het nog ruw was.\n\nEn aan ieder van jullie die een bug meldde, een string vertaalde, TREK deelde met een vriend of het simpelweg gebruikte om een reis te plannen — **bedankt**. Jullie zijn de reden dat dit bestaat.\n\nOp nog vele avonturen samen.\n\n— Maurice\n\n---\n\n[Sluit je aan bij de community op Discord](https://discord.gg/7Q6M6jDwzf)\n\nAls TREK je reizen beter maakt, houdt een [klein kopje koffie](https://ko-fi.com/mauriceboe) altijd de lichten aan.',
|
||||
}
|
||||
|
||||
export default nl
|
||||
|
||||
@@ -520,7 +520,21 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Packing Templates & Bag Tracking
|
||||
'admin.bagTracking.title': 'Kontrola bagażu',
|
||||
'admin.bagTracking.subtitle': 'Włącz wagę i przypisywanie do toreb dla przedmiotów do pakowania',
|
||||
'admin.collab.chat.title': 'Czat',
|
||||
'admin.collab.chat.subtitle': 'Wiadomości w czasie rzeczywistym',
|
||||
'admin.collab.notes.title': 'Notatki',
|
||||
'admin.collab.notes.subtitle': 'Wspólne notatki i dokumenty',
|
||||
'admin.collab.polls.title': 'Ankiety',
|
||||
'admin.collab.polls.subtitle': 'Ankiety grupowe i głosowania',
|
||||
'admin.collab.whatsnext.title': 'Co dalej',
|
||||
'admin.collab.whatsnext.subtitle': 'Sugestie aktywności i następne kroki',
|
||||
'admin.tabs.config': 'Personalizacja',
|
||||
'admin.tabs.defaults': 'Domyślne ustawienia',
|
||||
'admin.defaultSettings.title': 'Domyślne ustawienia użytkownika',
|
||||
'admin.defaultSettings.description': 'Ustaw domyślne wartości dla całej instancji. Użytkownicy, którzy nie zmienili ustawienia, zobaczą te wartości. Ich własne zmiany zawsze mają pierwszeństwo.',
|
||||
'admin.defaultSettings.saved': 'Domyślne zapisane',
|
||||
'admin.defaultSettings.reset': 'Przywróć wbudowaną wartość domyślną',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'przywróć',
|
||||
'admin.tabs.templates': 'Szablony pakowania',
|
||||
'admin.packingTemplates.title': 'Szablony pakowania',
|
||||
'admin.packingTemplates.subtitle': 'Twórz szablony list pakowania do wielokrotnego użycia dla swoich podróży',
|
||||
@@ -960,6 +974,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.meta.platform': 'Peron',
|
||||
'reservations.meta.seat': 'Miejsce',
|
||||
'reservations.meta.checkIn': 'Zameldowanie',
|
||||
'reservations.meta.checkInUntil': 'Check-in do',
|
||||
'reservations.meta.checkOut': 'Wymeldowanie',
|
||||
'reservations.meta.linkAccommodation': 'Zakwaterowanie',
|
||||
'reservations.meta.pickAccommodation': 'Link do zakwaterowania',
|
||||
@@ -1442,6 +1457,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'day.noPlacesForHotel': 'Najpierw dodaj miejsca do swojej podróży',
|
||||
'day.allDays': 'Wszystkie',
|
||||
'day.checkIn': 'Zameldowanie',
|
||||
'day.checkInUntil': 'Do',
|
||||
'day.checkOut': 'Wymeldowanie',
|
||||
'day.confirmation': 'Potwierdzenie',
|
||||
'day.editAccommodation': 'Edytuj zakwaterowanie',
|
||||
@@ -2159,6 +2175,50 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Wyszukuj miejsca, rozwiązuj adresy URL map i odwrotnie geokoduj współrzędne',
|
||||
'oauth.scope.weather:read.label': 'Prognozy pogody',
|
||||
'oauth.scope.weather:read.description': 'Pobieraj prognozy pogody dla miejsc i dat podróży',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Witaj w TREK',
|
||||
'system_notice.welcome_v1.body': 'Twój kompleksowy planer podróży. Twórz trasy, dziel się wycieczkami ze znajomymi i bądź zorganizowany — online i offline.',
|
||||
'system_notice.welcome_v1.cta_label': 'Zaplanuj podróż',
|
||||
'system_notice.welcome_v1.hero_alt': 'Malownicze miejsce z interfejsem planowania TREK',
|
||||
'system_notice.welcome_v1.highlight_plan': 'Trasy dzień po dniu',
|
||||
'system_notice.welcome_v1.highlight_share': 'Współpraca z partnerami podróży',
|
||||
'system_notice.welcome_v1.highlight_offline': 'Działa offline na telefonie',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'Poprzednie powiadomienie',
|
||||
'system_notice.pager.next': 'Następne powiadomienie',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'Przejdź do powiadomienia {n}',
|
||||
'system_notice.pager.position': 'Powiadomienie {current} z {total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'Zdjęcia zostały przeniesione w 3.0',
|
||||
'system_notice.v3_photos.body': '**Zdjęcia** w Planerze Podróży zostały usunięte. Twoje zdjęcia są bezpieczne — TREK nigdy nie modyfikował Twojej biblioteki Immich lub Synology.\n\nZdjęcia są teraz dostępne w dodatku **Journey**. Journey jest opcjonalny — jeśli jeszcze nie jest dostępny, poproś administratora o jego włączenie w Admin → Dodatki.',
|
||||
'system_notice.v3_journey.title': 'Poznaj Journey — dziennik podróży',
|
||||
'system_notice.v3_journey.body': 'Dokumentuj swoje podróże jako bogatrze opowieści z osami czasu, galeriami i mapami interaktywnymi.',
|
||||
'system_notice.v3_journey.cta_label': 'Otwórz Journey',
|
||||
'system_notice.v3_journey.highlight_timeline': 'Dzienna oś czasu i galeria',
|
||||
'system_notice.v3_journey.highlight_photos': 'Import z Immich lub Synology',
|
||||
'system_notice.v3_journey.highlight_share': 'Udostępnij publicznie — bez logowania',
|
||||
'system_notice.v3_journey.highlight_export': 'Eksportuj jako książkę fotograficzną PDF',
|
||||
'system_notice.v3_features.title': 'Więcej nowości w 3.0',
|
||||
'system_notice.v3_features.body': 'Kilka innych rzeczy wartych uwagi w tym wydaniu.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'Przeprojektowany pulpit mobile-first',
|
||||
'system_notice.v3_features.highlight_offline': 'Pełny tryb offline jako PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Autouzupełnianie wyszukiwania miejsc',
|
||||
'system_notice.v3_features.highlight_import': 'Import miejsc z plików KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: aktualizacja OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'Integracja MCP została całkowicie przeprojektowana. OAuth 2.1 jest teraz zalecaną metodą uwierzytelniania. Statyczne tokeny (trek_…) są przestarzałe i zostaną usunięte w przyszłej wersji.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 zalecany (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 szczegółowe zakresy uprawnień',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Statyczne tokeny trek_ przestarzałe',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Rozszerzony zestaw narzędzi i promptów',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Osobiste słowo ode mnie',
|
||||
'system_notice.v3_thankyou.body': 'Zanim pójdziesz dalej — chcę się na chwilę zatrzymać.\n\nTREK zaczął się jako poboczny projekt, który zbudowałem na własne podróże. Nigdy nie wyobrażałem sobie, że wyrośnie na coś, czemu 4000 z was ufa przy planowaniu swoich przygód. Każda gwiazdka, każdy issue, każda prośba o funkcję — czytam je wszystkie i to one trzymają mnie na nogach podczas późnych nocy między pracą na pełny etat a uczelnią.\n\nChcę, żebyście wiedzieli: TREK zawsze będzie open source, zawsze self-hosted, zawsze wasz. Bez śledzenia, bez subskrypcji, bez haczyków. Po prostu narzędzie zbudowane przez kogoś, kto kocha podróżowanie tak samo jak wy.\n\nSzczególne podziękowania dla [jubnl](https://github.com/jubnl) — stałeś się niesamowitym współpracownikiem. Tak wiele z tego, co czyni wersję 3.0 wspaniałą, nosi twój ślad. Dziękuję, że uwierzyłeś w ten projekt, gdy był jeszcze surowy.\n\nI każdemu z was, kto zgłosił błąd, przetłumaczył tekst, podzielił się TREK z przyjacielem lub po prostu użył go do zaplanowania podróży — **dziękuję**. To wy jesteście powodem, dla którego to istnieje.\n\nZa wiele kolejnych wspólnych przygód.\n\n— Maurice\n\n---\n\n[Dołącz do społeczności na Discordzie](https://discord.gg/7Q6M6jDwzf)\n\nJeśli TREK sprawia, że Twoje podróże są lepsze, [mała kawa](https://ko-fi.com/mauriceboe) zawsze pomaga utrzymać światła włączone.',
|
||||
}
|
||||
|
||||
export default pl
|
||||
|
||||
@@ -548,7 +548,21 @@ const ru: Record<string, string> = {
|
||||
|
||||
'admin.bagTracking.title': 'Отслеживание багажа',
|
||||
'admin.bagTracking.subtitle': 'Включить вес и привязку к багажу для вещей',
|
||||
'admin.collab.chat.title': 'Чат',
|
||||
'admin.collab.chat.subtitle': 'Обмен сообщениями для совместной работы',
|
||||
'admin.collab.notes.title': 'Заметки',
|
||||
'admin.collab.notes.subtitle': 'Общие заметки и документы',
|
||||
'admin.collab.polls.title': 'Опросы',
|
||||
'admin.collab.polls.subtitle': 'Групповые опросы и голосования',
|
||||
'admin.collab.whatsnext.title': 'Что дальше',
|
||||
'admin.collab.whatsnext.subtitle': 'Предложения активностей и следующие шаги',
|
||||
'admin.tabs.config': 'Персонализация',
|
||||
'admin.tabs.defaults': 'Настройки по умолчанию',
|
||||
'admin.defaultSettings.title': 'Настройки пользователей по умолчанию',
|
||||
'admin.defaultSettings.description': 'Задайте значения по умолчанию для всего экземпляра. Пользователи, не изменившие параметр, увидят эти значения. Их собственные изменения всегда имеют приоритет.',
|
||||
'admin.defaultSettings.saved': 'Значение по умолчанию сохранено',
|
||||
'admin.defaultSettings.reset': 'Сбросить до встроенного значения',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'сбросить',
|
||||
'admin.tabs.templates': 'Шаблоны упаковки',
|
||||
'admin.packingTemplates.title': 'Шаблоны упаковки',
|
||||
'admin.packingTemplates.subtitle': 'Создавайте многоразовые списки вещей для поездок',
|
||||
@@ -1003,6 +1017,7 @@ const ru: Record<string, string> = {
|
||||
'reservations.meta.platform': 'Платформа',
|
||||
'reservations.meta.seat': 'Место',
|
||||
'reservations.meta.checkIn': 'Заезд',
|
||||
'reservations.meta.checkInUntil': 'Заселение до',
|
||||
'reservations.meta.checkOut': 'Выезд',
|
||||
'reservations.meta.linkAccommodation': 'Жильё',
|
||||
'reservations.meta.pickAccommodation': 'Привязать к жилью',
|
||||
@@ -1487,6 +1502,7 @@ const ru: Record<string, string> = {
|
||||
'day.noPlacesForHotel': 'Сначала добавьте места в поездку',
|
||||
'day.allDays': 'Все',
|
||||
'day.checkIn': 'Заезд',
|
||||
'day.checkInUntil': 'До',
|
||||
'day.checkOut': 'Выезд',
|
||||
'day.confirmation': 'Подтверждение',
|
||||
'day.editAccommodation': 'Редактировать жильё',
|
||||
@@ -2166,6 +2182,50 @@ const ru: Record<string, string> = {
|
||||
'oauth.scope.geo:read.description': 'Поиск мест, разрешение URL карт и обратное геокодирование координат',
|
||||
'oauth.scope.weather:read.label': 'Прогнозы погоды',
|
||||
'oauth.scope.weather:read.description': 'Получение прогнозов погоды для мест и дат поездки',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Добро пожаловать в TREK',
|
||||
'system_notice.welcome_v1.body': 'Ваш универсальный планировщик путешествий. Создавайте маршруты, делитесь поездками с друзьями и оставайтесь организованными — онлайн и офлайн.',
|
||||
'system_notice.welcome_v1.cta_label': 'Спланировать поездку',
|
||||
'system_notice.welcome_v1.hero_alt': 'Живописное место назначения с интерфейсом TREK',
|
||||
'system_notice.welcome_v1.highlight_plan': 'Маршруты по дням',
|
||||
'system_notice.welcome_v1.highlight_share': 'Совместное планирование с партнёрами',
|
||||
'system_notice.welcome_v1.highlight_offline': 'Работает офлайн на мобильном',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'Предыдущее уведомление',
|
||||
'system_notice.pager.next': 'Следующее уведомление',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'Перейти к уведомлению {n}',
|
||||
'system_notice.pager.position': 'Уведомление {current} из {total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'Фото перемещены в версии 3.0',
|
||||
'system_notice.v3_photos.body': 'Вкладка **Фото** в Планировщике путешествий удалена. Ваши фото в безопасности — TREK никогда не изменял вашу библиотеку Immich или Synology.\n\nФото теперь доступны в дополнении **Journey**. Journey необязателен — если он ещё недоступен, попросите администратора включить его в разделе Admin → Дополнения.',
|
||||
'system_notice.v3_journey.title': 'Знакомьтесь с Journey',
|
||||
'system_notice.v3_journey.body': 'Документируйте путешествия в виде рассказов с хронологиями, фотогалереями и интерактивными картами.',
|
||||
'system_notice.v3_journey.cta_label': 'Открыть Journey',
|
||||
'system_notice.v3_journey.highlight_timeline': 'Ежедневная хронология и галерея',
|
||||
'system_notice.v3_journey.highlight_photos': 'Импорт из Immich или Synology',
|
||||
'system_notice.v3_journey.highlight_share': 'Общий доступ — без входа',
|
||||
'system_notice.v3_journey.highlight_export': 'Экспорт в PDF-фотокнигу',
|
||||
'system_notice.v3_features.title': 'Ещё нового в версии 3.0',
|
||||
'system_notice.v3_features.body': 'Несколько других важных новшеств в этом релизе.',
|
||||
'system_notice.v3_features.highlight_dashboard': 'Переработанная панель в mobile-first стиле',
|
||||
'system_notice.v3_features.highlight_offline': 'Полный офлайн-режим как PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Автодополнение поиска мест в реальном времени',
|
||||
'system_notice.v3_features.highlight_import': 'Импорт мест из KMZ/KML-файлов',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: обновление OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'Интеграция MCP была полностью переработана. OAuth 2.1 теперь является рекомендуемым методом аутентификации. Статические токены (trek_…) устарели и будут удалены в будущей версии.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 рекомендуется (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 детальных области разрешений',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Статические токены trek_ устарели',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Расширенный набор инструментов',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Личное слово от меня',
|
||||
'system_notice.v3_thankyou.body': 'Прежде чем продолжить — хочу остановиться на мгновение.\n\nTREK начинался как сторонний проект, который я создал для собственных поездок. Я никогда не думал, что он вырастет во что-то, чему 4 000 из вас доверяют планирование своих приключений. Каждая звёздочка, каждый issue, каждый запрос на фичу — я читаю их все, и именно они поддерживают меня в поздние ночи между основной работой и университетом.\n\nХочу, чтобы вы знали: TREK всегда будет open source, всегда self-hosted, всегда вашим. Никакого отслеживания, никаких подписок, никаких подвохов. Просто инструмент, созданный человеком, который любит путешествовать так же, как и вы.\n\nОсобая благодарность [jubnl](https://github.com/jubnl) — ты стал невероятным соратником. Многое из того, что делает версию 3.0 великолепной, несёт твой отпечаток. Спасибо, что поверил в этот проект, когда он был ещё сырым.\n\nИ каждому из вас, кто сообщил об ошибке, перевёл строку, поделился TREK с другом или просто использовал его для планирования поездки — **спасибо**. Вы — причина, по которой всё это существует.\n\nЗа множество новых приключений вместе.\n\n— Maurice\n\n---\n\n[Присоединяйся к сообществу в Discord](https://discord.gg/7Q6M6jDwzf)\n\nЕсли TREK делает твои путешествия лучше, [маленький кофе](https://ko-fi.com/mauriceboe) всегда помогает держать свет включённым.',
|
||||
}
|
||||
|
||||
export default ru
|
||||
|
||||
@@ -548,7 +548,21 @@ const zh: Record<string, string> = {
|
||||
|
||||
'admin.bagTracking.title': '行李追踪',
|
||||
'admin.bagTracking.subtitle': '为打包物品启用重量和行李分配',
|
||||
'admin.collab.chat.title': '聊天',
|
||||
'admin.collab.chat.subtitle': '实时消息协作',
|
||||
'admin.collab.notes.title': '笔记',
|
||||
'admin.collab.notes.subtitle': '共享笔记和文档',
|
||||
'admin.collab.polls.title': '投票',
|
||||
'admin.collab.polls.subtitle': '群组投票和表决',
|
||||
'admin.collab.whatsnext.title': '下一步',
|
||||
'admin.collab.whatsnext.subtitle': '活动建议和后续步骤',
|
||||
'admin.tabs.config': '个性化',
|
||||
'admin.tabs.defaults': '用户默认设置',
|
||||
'admin.defaultSettings.title': '用户默认设置',
|
||||
'admin.defaultSettings.description': '设置实例范围的默认值。未更改设置的用户将看到这些值。用户自己的更改始终优先。',
|
||||
'admin.defaultSettings.saved': '默认值已保存',
|
||||
'admin.defaultSettings.reset': '重置为内置默认值',
|
||||
'admin.defaultSettings.resetToBuiltIn': '重置',
|
||||
'admin.tabs.templates': '打包模板',
|
||||
'admin.packingTemplates.title': '打包模板',
|
||||
'admin.packingTemplates.subtitle': '创建可复用的旅行打包清单',
|
||||
@@ -1003,6 +1017,7 @@ const zh: Record<string, string> = {
|
||||
'reservations.meta.platform': '站台',
|
||||
'reservations.meta.seat': '座位',
|
||||
'reservations.meta.checkIn': '入住',
|
||||
'reservations.meta.checkInUntil': '入住截止',
|
||||
'reservations.meta.checkOut': '退房',
|
||||
'reservations.meta.linkAccommodation': '住宿',
|
||||
'reservations.meta.pickAccommodation': '关联住宿',
|
||||
@@ -1487,6 +1502,7 @@ const zh: Record<string, string> = {
|
||||
'day.noPlacesForHotel': '请先在旅行中添加地点',
|
||||
'day.allDays': '全部',
|
||||
'day.checkIn': '入住',
|
||||
'day.checkInUntil': '截止',
|
||||
'day.checkOut': '退房',
|
||||
'day.confirmation': '确认号',
|
||||
'day.editAccommodation': '编辑住宿',
|
||||
@@ -2166,6 +2182,50 @@ const zh: Record<string, string> = {
|
||||
'oauth.scope.geo:read.description': '搜索位置、解析地图 URL 和反向地理编码坐标',
|
||||
'oauth.scope.weather:read.label': '天气预报',
|
||||
'oauth.scope.weather:read.description': '获取行程地点和日期的天气预报',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': '欢迎使用 TREK',
|
||||
'system_notice.welcome_v1.body': '您的全能旅行规划器。制定行程、与朋友分享旅行,随时保持井然有序——在线或离线均可。',
|
||||
'system_notice.welcome_v1.cta_label': '规划行程',
|
||||
'system_notice.welcome_v1.hero_alt': '风景优美的旅游目的地与 TREK 界面',
|
||||
'system_notice.welcome_v1.highlight_plan': '逐日行程规划',
|
||||
'system_notice.welcome_v1.highlight_share': '与旅行伙伴协作',
|
||||
'system_notice.welcome_v1.highlight_offline': '移动端支持离线使用',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': '上一条通知',
|
||||
'system_notice.pager.next': '下一条通知',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': '转到通知 {n}',
|
||||
'system_notice.pager.position': '通知 {current}/{total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': '3.0 版照片已迁移',
|
||||
'system_notice.v3_photos.body': '行程规划器中的**照片**标签已被移除。您的照片安全无虑 — TREK 从未修改您的 Immich 或 Synology 相册。\n\n照片现在位于 **Journey** 插件中。Journey 是可选的 — 如果尚未启用,请联系管理员在 Admin → 插件 中开启。',
|
||||
'system_notice.v3_journey.title': '认识 Journey — 旅行日记',
|
||||
'system_notice.v3_journey.body': '将您的旅程记录为展示时间线、照片画廊和互动地图的丰富旅行故事。',
|
||||
'system_notice.v3_journey.cta_label': '打开 Journey',
|
||||
'system_notice.v3_journey.highlight_timeline': '每日时间线与画廊',
|
||||
'system_notice.v3_journey.highlight_photos': '从 Immich 或 Synology 导入',
|
||||
'system_notice.v3_journey.highlight_share': '公开分享 — 无需登录',
|
||||
'system_notice.v3_journey.highlight_export': '导出为 PDF 相册书',
|
||||
'system_notice.v3_features.title': '3.0 版更多亮点',
|
||||
'system_notice.v3_features.body': '此版本还有一些其他值得了解的新功能。',
|
||||
'system_notice.v3_features.highlight_dashboard': '移动优先仪表板重设计',
|
||||
'system_notice.v3_features.highlight_offline': '作为 PWA 的完整离线模式',
|
||||
'system_notice.v3_features.highlight_search': '地点搜索实时自动补全',
|
||||
'system_notice.v3_features.highlight_import': '从 KMZ/KML 文件导入地点',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP:OAuth 2.1 升级',
|
||||
'system_notice.v3_mcp.body': 'MCP 集成已全面重构。OAuth 2.1 现为推荐的身份验证方式。静态令牌(trek_…)已弃用,将在未来版本中移除。',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 推荐(mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 个细粒度权限范围',
|
||||
'system_notice.v3_mcp.highlight_deprecated': '静态 trek_ 令牌已弃用',
|
||||
'system_notice.v3_mcp.highlight_tools': '扩展工具集与提示词',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': '来自我的一封私人信',
|
||||
'system_notice.v3_thankyou.body': '在你继续之前——我想停下来说几句。\n\nTREK 最初只是我为自己的旅行而做的一个业余项目。我从未想过它会成长为 4,000 人信赖的冒险规划工具。每一颗星标、每一个 issue、每一个功能请求——我都会读,它们在全职工作和大学学业之间的深夜里支撑着我继续前行。\n\n我想让你们知道:TREK 将永远开源,永远可自托管,永远属于你们。没有追踪,没有订阅,没有任何附加条件。只是一个热爱旅行的人为同样热爱旅行的你们打造的工具。\n\n特别感谢 [jubnl](https://github.com/jubnl)——你已经成为一位不可思议的合作者。3.0 版本中许多精彩之处都留下了你的印记。感谢你在这个项目还很粗糙的时候就选择了相信它。\n\n也感谢你们每一位——报告了 bug、翻译了文本、向朋友分享了 TREK,或者只是用它规划了一次旅行——**谢谢你们**。你们是这一切存在的原因。\n\n愿我们一起踏上更多的冒险旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社区](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 让你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能让这盏灯一直亮着。',
|
||||
}
|
||||
|
||||
export default zh
|
||||
|
||||
@@ -604,7 +604,21 @@ const zhTw: Record<string, string> = {
|
||||
|
||||
'admin.bagTracking.title': '行李追蹤',
|
||||
'admin.bagTracking.subtitle': '為打包物品啟用重量和行李分配',
|
||||
'admin.collab.chat.title': '聊天',
|
||||
'admin.collab.chat.subtitle': '即時訊息協作',
|
||||
'admin.collab.notes.title': '筆記',
|
||||
'admin.collab.notes.subtitle': '共享筆記和文件',
|
||||
'admin.collab.polls.title': '投票',
|
||||
'admin.collab.polls.subtitle': '群組投票和表決',
|
||||
'admin.collab.whatsnext.title': '下一步',
|
||||
'admin.collab.whatsnext.subtitle': '活動建議和後續步驟',
|
||||
'admin.tabs.config': '配置',
|
||||
'admin.tabs.defaults': '用戶預設設定',
|
||||
'admin.defaultSettings.title': '用戶預設設定',
|
||||
'admin.defaultSettings.description': '設定整個執行個體的預設值。未更改設定的用戶將看到這些值。用戶自己的更改始終優先。',
|
||||
'admin.defaultSettings.saved': '預設值已儲存',
|
||||
'admin.defaultSettings.reset': '重設為內建預設值',
|
||||
'admin.defaultSettings.resetToBuiltIn': '重設',
|
||||
'admin.tabs.templates': '打包模板',
|
||||
'admin.packingTemplates.title': '打包模板',
|
||||
'admin.packingTemplates.subtitle': '建立可複用的旅行打包清單',
|
||||
@@ -1059,6 +1073,7 @@ const zhTw: Record<string, string> = {
|
||||
'reservations.meta.platform': '站臺',
|
||||
'reservations.meta.seat': '座位',
|
||||
'reservations.meta.checkIn': '入住',
|
||||
'reservations.meta.checkInUntil': '入住截止',
|
||||
'reservations.meta.checkOut': '退房',
|
||||
'reservations.meta.linkAccommodation': '住宿',
|
||||
'reservations.meta.pickAccommodation': '關聯住宿',
|
||||
@@ -1543,6 +1558,7 @@ const zhTw: Record<string, string> = {
|
||||
'day.noPlacesForHotel': '請先在旅行中新增地點',
|
||||
'day.allDays': '全部',
|
||||
'day.checkIn': '入住',
|
||||
'day.checkInUntil': '截止',
|
||||
'day.checkOut': '退房',
|
||||
'day.confirmation': '確認號',
|
||||
'day.editAccommodation': '編輯住宿',
|
||||
@@ -2167,6 +2183,50 @@ const zhTw: Record<string, string> = {
|
||||
'oauth.scope.geo:read.description': '搜尋地點、解析地圖 URL 及反向地理編碼坐標',
|
||||
'oauth.scope.weather:read.label': '天氣預報',
|
||||
'oauth.scope.weather:read.description': '取得行程地點及日期的天氣預報',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': '歡迎使用 TREK',
|
||||
'system_notice.welcome_v1.body': '您的全方位旅遊規劃器。建立行程、與朋友分享旅遊,隨時保持條理分明——無論線上或離線皆可。',
|
||||
'system_notice.welcome_v1.cta_label': '規劃行程',
|
||||
'system_notice.welcome_v1.hero_alt': '風景優美的旅遊目的地與 TREK 介面',
|
||||
'system_notice.welcome_v1.highlight_plan': '逐日行程規劃',
|
||||
'system_notice.welcome_v1.highlight_share': '與旅伴協作規劃',
|
||||
'system_notice.welcome_v1.highlight_offline': '行動裝置支援離線使用',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': '上一則通知',
|
||||
'system_notice.pager.next': '下一則通知',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': '前往通知 {n}',
|
||||
'system_notice.pager.position': '通知 {current}/{total}',
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': '3.0 版相片已移至',
|
||||
'system_notice.v3_photos.body': '行程規劃器中的**相片**標籤已被移除。您的相片安全— TREK 從未修改您的 Immich 或 Synology 相簿。\n\n相片現在位於 **Journey** 附加元件中。Journey 為選用 — 若尚未啟用,請聯絡管理員於 Admin → 附加元件 中開啟。',
|
||||
'system_notice.v3_journey.title': '認識 Journey — 旅行日記',
|
||||
'system_notice.v3_journey.body': '將您的旅程記錄為具有時間軸、相片畫庫與互動地圖的豐富旅行故事。',
|
||||
'system_notice.v3_journey.cta_label': '開啟 Journey',
|
||||
'system_notice.v3_journey.highlight_timeline': '每日時間軸與畫庫',
|
||||
'system_notice.v3_journey.highlight_photos': '從 Immich 或 Synology 匯入',
|
||||
'system_notice.v3_journey.highlight_share': '公開分享 — 無需登入',
|
||||
'system_notice.v3_journey.highlight_export': '匯出為 PDF 相簿书',
|
||||
'system_notice.v3_features.title': '3.0 版更多亮點',
|
||||
'system_notice.v3_features.body': '這個版本還有一些其他專項值得了解。',
|
||||
'system_notice.v3_features.highlight_dashboard': '行動先行儀表板重設計',
|
||||
'system_notice.v3_features.highlight_offline': '作為 PWA 的完整離線模式',
|
||||
'system_notice.v3_features.highlight_search': '地點搜尋即時自動補全',
|
||||
'system_notice.v3_features.highlight_import': '從 KMZ/KML 檔案匯入地點',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP:OAuth 2.1 升級',
|
||||
'system_notice.v3_mcp.body': 'MCP 整合已全面重構。OAuth 2.1 現為建議的身份驗證方式。靜態令牌(trek_…)已棄用,將於未來版本移除。',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 建議(mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 個細粒度權限範圍',
|
||||
'system_notice.v3_mcp.highlight_deprecated': '靜態 trek_ 令牌已棄用',
|
||||
'system_notice.v3_mcp.highlight_tools': '擴展工具集與提示詞',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': '來自我的一封私人信',
|
||||
'system_notice.v3_thankyou.body': '在你繼續之前——我想停下來說幾句。\n\nTREK 最初只是我為自己的旅行而做的一個業餘專案。我從未想過它會成長為 4,000 人信賴的冒險規劃工具。每一顆星標、每一個 issue、每一個功能請求——我都會讀,它們在全職工作和大學學業之間的深夜裡支撐著我繼續前行。\n\n我想讓你們知道:TREK 將永遠開源,永遠可自託管,永遠屬於你們。沒有追蹤,沒有訂閱,沒有任何附加條件。只是一個熱愛旅行的人為同樣熱愛旅行的你們打造的工具。\n\n特別感謝 [jubnl](https://github.com/jubnl)——你已經成為一位不可思議的合作者。3.0 版本中許多精彩之處都留下了你的印記。感謝你在這個專案還很粗糙的時候就選擇了相信它。\n\n也感謝你們每一位——回報了 bug、翻譯了文字、向朋友分享了 TREK,或者只是用它規劃了一次旅行——**謝謝你們**。你們是這一切存在的原因。\n\n願我們一起踏上更多的冒險旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社群](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 讓你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能讓這盞燈一直亮著。',
|
||||
}
|
||||
|
||||
export default zhTw
|
||||
@@ -323,7 +323,7 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Scrollbalken */
|
||||
/* Scrollbars — styled on desktop, hidden on mobile */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -333,21 +333,23 @@ body {
|
||||
height: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--scrollbar-track);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
* { scrollbar-width: none; }
|
||||
::-webkit-scrollbar { width: 0; height: 0; }
|
||||
}
|
||||
|
||||
.route-info-pill { background: none !important; border: none !important; box-shadow: none !important; width: auto !important; height: auto !important; margin: 0 !important; }
|
||||
.chat-scroll { overflow-y: auto !important; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
|
||||
.chat-scroll::-webkit-scrollbar { width: 0; background: transparent; }
|
||||
@@ -405,6 +407,7 @@ img[alt="TREK"] {
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
}
|
||||
|
||||
@@ -447,11 +450,6 @@ img[alt="TREK"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Scroll-Container */
|
||||
.scroll-container {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #d1d5db #f1f5f9;
|
||||
}
|
||||
|
||||
/* Toast-Animationen */
|
||||
@keyframes slideUp {
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import apiClient, { adminApi, authApi, notificationsApi } from '../api/client'
|
||||
import DevNotificationsPanel from '../components/Admin/DevNotificationsPanel'
|
||||
import DefaultUserSettingsTab from '../components/Admin/DefaultUserSettingsTab'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { useAddonStore } from '../store/addonStore'
|
||||
@@ -169,6 +170,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
const TABS = [
|
||||
{ id: 'users', label: t('admin.tabs.users') },
|
||||
{ id: 'config', label: t('admin.tabs.config') },
|
||||
{ id: 'defaults', label: t('admin.tabs.defaults') },
|
||||
{ id: 'addons', label: t('admin.tabs.addons') },
|
||||
{ id: 'settings', label: t('admin.tabs.settings') },
|
||||
{ id: 'notifications', label: t('admin.tabs.notifications') },
|
||||
@@ -192,6 +194,10 @@ export default function AdminPage(): React.ReactElement {
|
||||
const [bagTrackingEnabled, setBagTrackingEnabled] = useState<boolean>(false)
|
||||
useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, [])
|
||||
|
||||
// Collab features
|
||||
const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, polls: true, whatsnext: true })
|
||||
useEffect(() => { adminApi.getCollabFeatures().then(d => setCollabFeatures(d)).catch(() => {}) }, [])
|
||||
|
||||
// OIDC config
|
||||
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', discovery_url: '' })
|
||||
const [savingOidc, setSavingOidc] = useState<boolean>(false)
|
||||
@@ -797,6 +803,10 @@ export default function AdminPage(): React.ReactElement {
|
||||
const next = !bagTrackingEnabled
|
||||
setBagTrackingEnabled(next)
|
||||
try { await adminApi.updateBagTracking(next) } catch { setBagTrackingEnabled(!next) }
|
||||
}} collabFeatures={collabFeatures} onToggleCollabFeature={async (key: string) => {
|
||||
const next = { ...collabFeatures, [key]: !collabFeatures[key] }
|
||||
setCollabFeatures(next)
|
||||
try { await adminApi.updateCollabFeatures({ [key]: next[key] }) } catch { setCollabFeatures(collabFeatures) }
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
@@ -1493,6 +1503,8 @@ export default function AdminPage(): React.ReactElement {
|
||||
|
||||
{activeTab === 'github' && <GitHubPanel isPrerelease={updateInfo?.is_prerelease ?? false} />}
|
||||
|
||||
{activeTab === 'defaults' && <DefaultUserSettingsTab />}
|
||||
|
||||
{activeTab === 'dev-notifications' && <DevNotificationsPanel />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { tripsApi } from '../api/client'
|
||||
import { tripRepo } from '../repo/tripRepo'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
@@ -689,6 +689,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
}
|
||||
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const { demoMode, user } = useAuthStore()
|
||||
@@ -709,6 +710,13 @@ export default function DashboardPage(): React.ReactElement {
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [showWidgetSettings])
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('create') === '1') {
|
||||
setShowForm(true)
|
||||
setSearchParams({}, { replace: true })
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
useEffect(() => { loadTrips() }, [])
|
||||
|
||||
const loadTrips = async () => {
|
||||
|
||||
@@ -674,7 +674,10 @@ describe('JourneyDetailPage', () => {
|
||||
// ── FE-PAGE-JOURNEYDETAIL-027 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-027: Shows loading spinner before data loads', () => {
|
||||
it('renders a spinner while journey data is loading', () => {
|
||||
// Do NOT await the waitFor -- we check the loading state before data arrives
|
||||
// Pre-seed the store into a loading state (current: null, loading: true).
|
||||
// We can't rely on render() timing because RTL wraps in act(), which flushes
|
||||
// all microtasks including the MSW response before render() returns.
|
||||
useJourneyStore.setState({ loading: true, current: null });
|
||||
render(<JourneyDetailPage />);
|
||||
// The spinner has animate-spin class on a div
|
||||
const spinner = document.querySelector('.animate-spin');
|
||||
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
Laugh, Smile, Meh, Annoyed, Frown,
|
||||
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Eye, EyeOff,
|
||||
} from 'lucide-react'
|
||||
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
||||
import MobileEntryView from '../components/Journey/MobileEntryView'
|
||||
import { useIsMobile } from '../hooks/useIsMobile'
|
||||
import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore'
|
||||
|
||||
const GRADIENTS = [
|
||||
@@ -84,7 +87,9 @@ export default function JourneyDetailPage() {
|
||||
const fullMapRef = useRef<JourneyMapHandle>(null)
|
||||
const [activeLocationId, setActiveLocationId] = useState<string | null>(null)
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
|
||||
const [viewingEntry, setViewingEntry] = useState<JourneyEntry | null>(null)
|
||||
const [editingEntry, setEditingEntry] = useState<JourneyEntry | null>(null)
|
||||
const [lightbox, setLightbox] = useState<{ photos: { id: number; src: string; caption?: string | null; provider?: string; asset_id?: string | null; owner_id?: number | null }[]; index: number } | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<JourneyEntry | null>(null)
|
||||
@@ -202,10 +207,60 @@ export default function JourneyDetailPage() {
|
||||
const dayGroups = groupByDate(timelineEntries)
|
||||
const sortedDates = [...dayGroups.keys()].sort()
|
||||
|
||||
const showMobileCombined = isMobile && view === 'timeline'
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||
<Navbar />
|
||||
<div style={{ paddingTop: 'var(--nav-h, 0px)' }}>
|
||||
|
||||
{/* Mobile combined map+timeline (Polarsteps-style) — renders as fullscreen overlay */}
|
||||
{showMobileCombined && (
|
||||
<MobileMapTimeline
|
||||
entries={timelineEntries}
|
||||
mapEntries={sidebarMapItems}
|
||||
dark={document.documentElement.classList.contains('dark')}
|
||||
onEntryClick={(entry) => setViewingEntry(entry)}
|
||||
onAddEntry={() => {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
setEditingEntry({ id: 0, journey_id: current.id, author_id: 0, type: 'entry', entry_date: today, visibility: 'private', sort_order: 0, photos: [], created_at: 0, updated_at: 0 } as JourneyEntry)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fullscreen entry view (mobile) */}
|
||||
{viewingEntry && (
|
||||
<MobileEntryView
|
||||
entry={viewingEntry}
|
||||
onClose={() => setViewingEntry(null)}
|
||||
onEdit={() => { setViewingEntry(null); setEditingEntry(viewingEntry); }}
|
||||
onDelete={() => { setViewingEntry(null); setDeleteTarget(viewingEntry); }}
|
||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Floating tab toggle on mobile combined view */}
|
||||
{showMobileCombined && (
|
||||
<div className="fixed top-[calc(var(--nav-h,56px)+12px)] left-4 z-30">
|
||||
<div className="flex bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden shadow-lg">
|
||||
<button
|
||||
onClick={() => setView('timeline')}
|
||||
className="flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium bg-zinc-900 dark:bg-white text-white dark:text-zinc-900"
|
||||
>
|
||||
<MapPin size={13} />
|
||||
{t('journey.detail.journeyTab') || 'Journey'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('gallery')}
|
||||
className="flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
|
||||
>
|
||||
<Grid size={13} />
|
||||
{t('journey.share.gallery')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ paddingTop: 'var(--nav-h, 0px)' }} className={showMobileCombined ? 'hidden' : ''}>
|
||||
<div className="max-w-[1440px] mx-auto px-0 md:px-8 pt-0 md:py-6">
|
||||
|
||||
{/* Back link — desktop */}
|
||||
@@ -298,11 +353,17 @@ export default function JourneyDetailPage() {
|
||||
{/* View Controls */}
|
||||
<div className="flex items-center justify-between mt-5 mb-5">
|
||||
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden">
|
||||
{[
|
||||
{ id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
|
||||
{ id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
|
||||
{ id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
|
||||
].map(v => (
|
||||
{(isMobile
|
||||
? [
|
||||
{ id: 'timeline' as const, icon: MapPin, label: t('journey.detail.journeyTab') || 'Journey' },
|
||||
{ id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
|
||||
]
|
||||
: [
|
||||
{ id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
|
||||
{ id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
|
||||
{ id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
|
||||
]
|
||||
).map(v => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setView(v.id)}
|
||||
@@ -317,21 +378,21 @@ export default function JourneyDetailPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{view === 'timeline' && (
|
||||
{(!isMobile ? view === 'timeline' : view !== 'gallery') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
setEditingEntry({ id: 0, journey_id: current.id, author_id: 0, type: 'entry', entry_date: today, visibility: 'private', sort_order: 0, photos: [], created_at: 0, updated_at: 0 } as JourneyEntry)
|
||||
}}
|
||||
className="w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center hover:bg-zinc-800 dark:hover:bg-zinc-100"
|
||||
className={`w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center hover:bg-zinc-800 dark:hover:bg-zinc-100 ${isMobile && view === 'timeline' ? 'hidden' : ''}`}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
{view === 'timeline' && (
|
||||
{/* Timeline (desktop only — mobile uses fullscreen combined view above) */}
|
||||
{!isMobile && view === 'timeline' && (
|
||||
<div className="flex flex-col gap-6 pb-24 md:pb-6">
|
||||
{sortedDates.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
@@ -398,8 +459,8 @@ export default function JourneyDetailPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Full Map View */}
|
||||
{view === 'map' && <div className="pb-24 md:pb-6"><MapView
|
||||
{/* Full Map View (desktop only — mobile uses combined view) */}
|
||||
{!isMobile && view === 'map' && <div className="pb-24 md:pb-6"><MapView
|
||||
entries={current.entries}
|
||||
mapEntries={mapEntries}
|
||||
sortedDates={sortedDates}
|
||||
@@ -908,11 +969,11 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6">
|
||||
{allPhotos.map(({ photo, entry }) => (
|
||||
{allPhotos.map(({ photo, entry }, i) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
|
||||
onClick={() => onPhotoClick(entry.photos, entry.photos.indexOf(photo))}
|
||||
onClick={() => onPhotoClick(allPhotos.map(a => a.photo), i)}
|
||||
>
|
||||
<img
|
||||
src={photoUrl(photo, 'thumbnail')}
|
||||
@@ -1423,6 +1484,24 @@ function ScrollTrigger({ onVisible, loading }: { onVisible: () => void; loading:
|
||||
)
|
||||
}
|
||||
|
||||
// ── Photo date grouping ───────────────────────────────────────────────────
|
||||
|
||||
function groupPhotosByDate(photos: any[]): { date: string; label: string; assets: any[] }[] {
|
||||
const map = new Map<string, any[]>()
|
||||
for (const asset of photos) {
|
||||
const key = asset.takenAt ? asset.takenAt.slice(0, 10) : '__unknown__'
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key)!.push(asset)
|
||||
}
|
||||
return [...map.entries()].map(([date, assets]) => ({
|
||||
date,
|
||||
label: date === '__unknown__'
|
||||
? 'Unknown date'
|
||||
: new Date(date + 'T00:00:00').toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }),
|
||||
assets,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Provider Picker ───────────────────────────────────────────────────────
|
||||
|
||||
function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd }: {
|
||||
@@ -1437,8 +1516,9 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
const { t } = useTranslation()
|
||||
const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
|
||||
const [photos, setPhotos] = useState<any[]>([])
|
||||
const [albums, setAlbums] = useState<any[]>([])
|
||||
const [albums, setAlbums] = useState<Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>>([])
|
||||
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null)
|
||||
const [selectedAlbumPassphrase, setSelectedAlbumPassphrase] = useState<string | undefined>(undefined)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
@@ -1500,13 +1580,14 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
searchPhotos(searchFrom, searchTo, searchPage + 1, true)
|
||||
}
|
||||
|
||||
const loadAlbumPhotos = async (albumId: string) => {
|
||||
const loadAlbumPhotos = async (album: { id: string; passphrase?: string }) => {
|
||||
const signal = cancelPending()
|
||||
setLoading(true)
|
||||
setPhotos([])
|
||||
setHasMore(false)
|
||||
try {
|
||||
const res = await fetch(`/api/integrations/memories/${provider}/albums/${albumId}/photos`, { credentials: 'include', signal })
|
||||
const qs = album.passphrase ? `?passphrase=${encodeURIComponent(album.passphrase)}` : ''
|
||||
const res = await fetch(`/api/integrations/memories/${provider}/albums/${album.id}/photos${qs}`, { credentials: 'include', signal })
|
||||
if (res.ok) setPhotos((await res.json()).assets || [])
|
||||
} catch (e: any) { if (e.name !== 'AbortError') {} }
|
||||
if (!signal.aborted) setLoading(false)
|
||||
@@ -1547,7 +1628,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
: t('journey.picker.newGallery')
|
||||
|
||||
return (
|
||||
<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="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[85vh] flex flex-col overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
@@ -1625,7 +1706,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
{albums.map((a: any) => (
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={() => { setSelectedAlbum(a.id); loadAlbumPhotos(a.id) }}
|
||||
onClick={() => { setSelectedAlbum(a.id); setSelectedAlbumPassphrase(a.passphrase); loadAlbumPhotos(a) }}
|
||||
className={`px-2.5 py-1 rounded-lg text-[11px] font-medium whitespace-nowrap flex-shrink-0 border ${
|
||||
selectedAlbum === a.id
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
|
||||
@@ -1732,51 +1813,60 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5">
|
||||
{photos.map((asset: any) => {
|
||||
const isSelected = selected.has(asset.id)
|
||||
const alreadyAdded = existingAssetIds.has(asset.id)
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
|
||||
className={`relative aspect-square rounded-lg overflow-hidden ${
|
||||
alreadyAdded
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: isSelected
|
||||
? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={e => {
|
||||
const img = e.currentTarget
|
||||
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original`
|
||||
if (!img.src.includes('/original')) img.src = original
|
||||
}}
|
||||
/>
|
||||
{alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-500 text-white flex items-center justify-center">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{isSelected && !alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{asset.city && (
|
||||
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/50 to-transparent">
|
||||
<p className="text-[8px] text-white truncate">{asset.city}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{groupPhotosByDate(photos).map(group => (
|
||||
<div key={group.date}>
|
||||
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400 mb-2 mt-4 first:mt-0">
|
||||
{group.label}
|
||||
</p>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5 mb-1">
|
||||
{group.assets.map((asset: any) => {
|
||||
const isSelected = selected.has(asset.id)
|
||||
const alreadyAdded = existingAssetIds.has(asset.id)
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
|
||||
className={`relative aspect-square rounded-lg overflow-hidden ${
|
||||
alreadyAdded
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: isSelected
|
||||
? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={e => {
|
||||
const img = e.currentTarget
|
||||
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`
|
||||
if (!img.src.includes('/original')) img.src = original
|
||||
}}
|
||||
/>
|
||||
{alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-500 text-white flex items-center justify-center">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{isSelected && !alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{asset.city && (
|
||||
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/50 to-transparent">
|
||||
<p className="text-[8px] text-white truncate">{asset.city}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{/* Infinite scroll trigger */}
|
||||
{hasMore && !selectedAlbum && <ScrollTrigger onVisible={loadMorePhotos} loading={loadingMore} />}
|
||||
</div>
|
||||
@@ -2000,8 +2090,9 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="fixed inset-0 z-[9999] flex items-end sm:items-center sm:justify-center sm:p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
|
||||
<div className="bg-white dark:bg-zinc-900 sm:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] sm:max-w-[640px] w-full flex flex-col overflow-hidden h-full sm:h-auto sm:max-h-[90vh]" style={{ paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}</h2>
|
||||
@@ -2158,7 +2249,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{pros.map((p, i) => (
|
||||
<div key={i} className="flex items-center gap-2 h-9 px-3 bg-green-50 dark:bg-green-900/10 border border-green-200 dark:border-green-800/30 rounded-[10px]">
|
||||
<div key={i} className="flex items-center gap-2 h-9 px-3 border rounded-[10px] border-zinc-200 dark:border-zinc-700">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0" />
|
||||
<input
|
||||
value={p}
|
||||
@@ -2192,7 +2283,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{cons.map((c, i) => (
|
||||
<div key={i} className="flex items-center gap-2 h-9 px-3 bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-800/30 rounded-[10px]">
|
||||
<div key={i} className="flex items-center gap-2 h-9 px-3 border rounded-[10px] border-zinc-200 dark:border-zinc-700">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0" />
|
||||
<input
|
||||
value={c}
|
||||
@@ -2256,7 +2347,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
/>
|
||||
{locationLat && (
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<MapPin size={13} className="text-emerald-500" />
|
||||
<MapPin size={13} className="text-zinc-500 dark:text-zinc-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2303,8 +2394,10 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
const active = mood === key
|
||||
return (
|
||||
<button key={key} onClick={() => setMood(active ? '' : key)}
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium border transition-all"
|
||||
style={{ background: active ? config.bg : 'transparent', color: active ? config.text : '#71717A', borderColor: active ? config.text + '30' : '#E4E4E7' }}>
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium border transition-all ${
|
||||
active ? '' : 'border-zinc-200 dark:border-zinc-700 text-zinc-500'
|
||||
}`}
|
||||
style={active ? { background: config.bg, color: config.text, borderColor: config.text + '30' } : undefined}>
|
||||
<Icon size={12} />
|
||||
{t(config.label)}
|
||||
</button>
|
||||
@@ -2334,7 +2427,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50" style={{ paddingBottom: 'max(16px, env(safe-area-inset-bottom, 16px))' }}>
|
||||
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
||||
<button onClick={handleSave} disabled={saving} 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-50">
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
@@ -2384,7 +2477,7 @@ function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<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="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[420px] w-full flex flex-col overflow-hidden">
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
@@ -2481,7 +2574,7 @@ function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvite
|
||||
}
|
||||
|
||||
return (
|
||||
<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="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[420px] w-full flex flex-col overflow-hidden">
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
@@ -2738,7 +2831,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'var(--bottom-nav-h)' }} onClick={e => e.stopPropagation()}>
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
|
||||
@@ -279,7 +279,7 @@ export default function JourneyPage() {
|
||||
{/* 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">
|
||||
<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" style={{ paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-7 pt-6 pb-5 border-b border-zinc-200 dark:border-zinc-700">
|
||||
|
||||
@@ -7,6 +7,8 @@ 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'
|
||||
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
||||
import { useIsMobile } from '../hooks/useIsMobile'
|
||||
|
||||
interface PublicEntry {
|
||||
id: number
|
||||
@@ -62,6 +64,7 @@ export default function JourneyPublicPage() {
|
||||
const [data, setData] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(false)
|
||||
const isMobile = useIsMobile()
|
||||
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()
|
||||
@@ -202,8 +205,20 @@ export default function JourneyPublicPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
{view === 'timeline' && perms.share_timeline && (
|
||||
{/* Mobile combined map+timeline (public, read-only) */}
|
||||
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
|
||||
<MobileMapTimeline
|
||||
entries={entries}
|
||||
mapEntries={mapEntries.map(e => ({ id: String(e.id), lat: e.location_lat!, lng: e.location_lng!, title: e.title, mood: e.mood, entry_date: e.entry_date }))}
|
||||
dark={document.documentElement.classList.contains('dark')}
|
||||
readOnly
|
||||
onEntryClick={() => {}}
|
||||
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Timeline (desktop, or mobile without map permission) */}
|
||||
{(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{sortedDates.map(date => {
|
||||
const dayEntries = groupedEntries.get(date)!
|
||||
|
||||
@@ -100,6 +100,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
}, [undo, lastActionLabel, toast])
|
||||
|
||||
const [enabledAddons, setEnabledAddons] = useState<Record<string, boolean>>({ packing: true, budget: true, documents: true, collab: false })
|
||||
const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, polls: true, whatsnext: true })
|
||||
const [tripAccommodations, setTripAccommodations] = useState<Accommodation[]>([])
|
||||
const [allowedFileTypes, setAllowedFileTypes] = useState<string | null>(null)
|
||||
const [tripMembers, setTripMembers] = useState<TripMember[]>([])
|
||||
@@ -116,6 +117,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
const map = {}
|
||||
data.addons.forEach(a => { map[a.id] = true })
|
||||
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab })
|
||||
if (data.collabFeatures) setCollabFeatures(data.collabFeatures)
|
||||
}).catch(() => {})
|
||||
authApi.getAppConfig().then(config => {
|
||||
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
||||
@@ -246,7 +248,11 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
|
||||
return places.filter(p => {
|
||||
if (!p.lat || !p.lng) return false
|
||||
if (mapCategoryFilter.size > 0 && !mapCategoryFilter.has(String(p.category_id))) return false
|
||||
if (mapCategoryFilter.size > 0) {
|
||||
if (p.category_id == null) {
|
||||
if (!mapCategoryFilter.has('uncategorized')) return false
|
||||
} else if (!mapCategoryFilter.has(String(p.category_id))) return false
|
||||
}
|
||||
if (hiddenPlaceIds.has(p.id)) return false
|
||||
if (plannedIds && plannedIds.has(p.id)) return false
|
||||
return true
|
||||
@@ -832,7 +838,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
)}
|
||||
|
||||
{selectedPlace && isMobile && ReactDOM.createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', background: 'rgba(0,0,0,0.3)' }} onClick={() => setSelectedPlaceId(null)}>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', background: 'rgba(0,0,0,0.3)', paddingBottom: 'var(--bottom-nav-h)' }} onClick={() => setSelectedPlaceId(null)}>
|
||||
<div style={{ width: '100%', maxHeight: '85vh' }} onClick={e => e.stopPropagation()}>
|
||||
<PlaceInspector
|
||||
place={selectedPlace}
|
||||
@@ -906,7 +912,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
)}
|
||||
|
||||
{activeTab === 'buchungen' && (
|
||||
<div style={{ height: '100%', maxWidth: 1200, margin: '0 auto', width: '100%', display: 'flex', flexDirection: 'column', overflowY: 'auto', overscrollBehavior: 'contain', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
<div style={{ height: '100%', maxWidth: 1800, margin: '0 auto', width: '100%', display: 'flex', flexDirection: 'column', overflowY: 'auto', overscrollBehavior: 'contain', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
<ReservationsPanel
|
||||
tripId={tripId}
|
||||
reservations={reservations}
|
||||
@@ -952,7 +958,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
|
||||
{activeTab === 'collab' && (
|
||||
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
|
||||
<CollabPanel tripId={tripId} tripMembers={tripMembers} />
|
||||
<CollabPanel tripId={tripId} tripMembers={tripMembers} collabFeatures={collabFeatures} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { registerNoticeAction } from '../../components/SystemNotices/noticeActions.js';
|
||||
|
||||
// Opens the new-trip creation modal on DashboardPage via URL param.
|
||||
// DashboardPage reads ?create=1 on mount and calls setShowForm(true).
|
||||
registerNoticeAction('open:trip-create', ({ navigate }) => {
|
||||
navigate('/dashboard?create=1');
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import type { User } from '../types'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
import { tripSyncManager } from '../sync/tripSyncManager'
|
||||
import { clearAll } from '../db/offlineDb'
|
||||
import { useSystemNoticeStore } from './systemNoticeStore.js'
|
||||
|
||||
interface AuthResponse {
|
||||
user: User
|
||||
@@ -91,6 +92,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
})
|
||||
connect()
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
if (!data.user?.must_change_password) {
|
||||
useSystemNoticeStore.getState().fetch()
|
||||
}
|
||||
return data as AuthResponse
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, 'Login failed')
|
||||
@@ -112,6 +116,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
})
|
||||
connect()
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
if (!data.user?.must_change_password) {
|
||||
useSystemNoticeStore.getState().fetch()
|
||||
}
|
||||
return data as AuthResponse
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, 'Verification failed')
|
||||
@@ -133,6 +140,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
})
|
||||
connect()
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
useSystemNoticeStore.getState().fetch()
|
||||
return data
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, 'Registration failed')
|
||||
@@ -143,6 +151,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
|
||||
logout: () => {
|
||||
disconnect()
|
||||
useSystemNoticeStore.getState().reset()
|
||||
// Tell server to clear the httpOnly cookie
|
||||
fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
|
||||
// Clear service worker caches containing sensitive data
|
||||
|
||||
@@ -314,6 +314,47 @@ describe('journeyStore', () => {
|
||||
expect(storedEntry?.photos[0].id).toBe(201);
|
||||
});
|
||||
|
||||
// ── loadJourney silent refresh ───────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-016: loadJourney does not set loading when refreshing same journey', async () => {
|
||||
const existing = buildJourneyDetail({ id: 5, title: 'Old' });
|
||||
useJourneyStore.setState({ current: existing, loading: false });
|
||||
|
||||
const loadingValues: boolean[] = [];
|
||||
const unsub = useJourneyStore.subscribe(s => loadingValues.push(s.loading));
|
||||
|
||||
const refreshed = buildJourneyDetail({ id: 5, title: 'Refreshed' });
|
||||
server.use(
|
||||
http.get('/api/journeys/5', () => HttpResponse.json(refreshed))
|
||||
);
|
||||
|
||||
await useJourneyStore.getState().loadJourney(5);
|
||||
unsub();
|
||||
|
||||
expect(loadingValues.every(v => v === false)).toBe(true);
|
||||
expect(useJourneyStore.getState().current?.title).toBe('Refreshed');
|
||||
});
|
||||
|
||||
it('FE-STORE-JOURNEY-017: loadJourney sets loading on cold load (different journey)', async () => {
|
||||
const existing = buildJourneyDetail({ id: 5 });
|
||||
useJourneyStore.setState({ current: existing, loading: false });
|
||||
|
||||
const loadingValues: boolean[] = [];
|
||||
const unsub = useJourneyStore.subscribe(s => loadingValues.push(s.loading));
|
||||
|
||||
const other = buildJourneyDetail({ id: 99 });
|
||||
server.use(
|
||||
http.get('/api/journeys/99', () => HttpResponse.json(other))
|
||||
);
|
||||
|
||||
await useJourneyStore.getState().loadJourney(99);
|
||||
unsub();
|
||||
|
||||
expect(loadingValues).toContain(true);
|
||||
expect(useJourneyStore.getState().current?.id).toBe(99);
|
||||
expect(useJourneyStore.getState().loading).toBe(false);
|
||||
});
|
||||
|
||||
// ── clear ────────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-015: clear resets state', () => {
|
||||
|
||||
@@ -124,7 +124,8 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
||||
},
|
||||
|
||||
loadJourney: async (id) => {
|
||||
set({ loading: true, notFound: false })
|
||||
const cold = get().current?.id !== id
|
||||
if (cold) set({ loading: true, notFound: false })
|
||||
try {
|
||||
const data = await journeyApi.get(id)
|
||||
set({ current: data })
|
||||
@@ -134,7 +135,7 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
set({ loading: false })
|
||||
if (cold) set({ loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { create } from 'zustand';
|
||||
import axios from '../api/client.js';
|
||||
|
||||
// Type mirrors SystemNoticeDTO from the server (copy here to avoid cross-package import)
|
||||
export interface SystemNoticeDTO {
|
||||
id: string;
|
||||
display: 'modal' | 'banner' | 'toast';
|
||||
severity: 'info' | 'warn' | 'critical';
|
||||
titleKey: string;
|
||||
bodyKey: string;
|
||||
bodyParams?: Record<string, string>;
|
||||
icon?: string;
|
||||
media?: {
|
||||
src: string;
|
||||
srcDark?: string;
|
||||
altKey: string;
|
||||
placement?: 'hero' | 'inline';
|
||||
aspectRatio?: string;
|
||||
};
|
||||
highlights?: Array<{ labelKey: string; iconName?: string }>;
|
||||
cta?: (
|
||||
| { kind: 'nav'; labelKey: string; href: string }
|
||||
| { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean }
|
||||
);
|
||||
dismissible: boolean;
|
||||
}
|
||||
|
||||
interface SystemNoticeState {
|
||||
notices: SystemNoticeDTO[];
|
||||
loaded: boolean;
|
||||
fetching: boolean;
|
||||
fetch: () => Promise<void>;
|
||||
dismiss: (id: string) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useSystemNoticeStore = create<SystemNoticeState>()((set, get) => ({
|
||||
notices: [],
|
||||
loaded: false,
|
||||
fetching: false,
|
||||
|
||||
async fetch() {
|
||||
if (get().fetching || get().loaded) return;
|
||||
set({ fetching: true });
|
||||
try {
|
||||
const res = await axios.get<SystemNoticeDTO[]>('/system-notices/active');
|
||||
set({ notices: res.data, loaded: true, fetching: false });
|
||||
} catch (err) {
|
||||
// Notices are non-critical. Fail silently; set loaded so UI doesn't hang.
|
||||
console.warn('[systemNotices] failed to fetch:', err);
|
||||
set({ loaded: true, fetching: false });
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
set({ notices: [], loaded: false, fetching: false });
|
||||
},
|
||||
|
||||
dismiss(id: string) {
|
||||
// Optimistic: remove immediately
|
||||
const prev = get().notices;
|
||||
set({ notices: prev.filter(n => n.id !== id) });
|
||||
|
||||
// POST in background; retry once on error
|
||||
const post = () => axios.post(`/system-notices/${id}/dismiss`);
|
||||
post().catch(() => {
|
||||
setTimeout(() => {
|
||||
post().catch(e => console.warn('[systemNotices] dismiss failed:', e));
|
||||
}, 2000);
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -241,6 +241,7 @@ export interface Accommodation {
|
||||
name: string
|
||||
address: string | null
|
||||
check_in: string | null
|
||||
check_in_end: string | null
|
||||
check_out: string | null
|
||||
confirmation_number: string | null
|
||||
notes: string | null
|
||||
|
||||
@@ -0,0 +1,754 @@
|
||||
# System Notices — Technical Documentation & Dev Guide
|
||||
|
||||
System notices are server-evaluated, user-targeted messages shown in the TREK UI as modals, banners, or toasts. They are used for onboarding, upgrade announcements, breaking change warnings, and time-boxed campaigns. Every aspect — targeting, display, copy, and dismissal — is controlled from one place: the server-side registry.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Architecture overview](#1-architecture-overview)
|
||||
2. [Data flow](#2-data-flow)
|
||||
3. [Database schema](#3-database-schema)
|
||||
4. [The notice registry](#4-the-notice-registry)
|
||||
5. [Notice fields reference](#5-notice-fields-reference)
|
||||
6. [Condition system](#6-condition-system)
|
||||
7. [Display types](#7-display-types)
|
||||
8. [CTAs (call to action)](#8-ctas-call-to-action)
|
||||
9. [i18n — translation keys](#9-i18n--translation-keys)
|
||||
10. [Client store & dismissal](#10-client-store--dismissal)
|
||||
11. [Sorting & priority](#11-sorting--priority)
|
||||
12. [How-to recipes](#12-how-to-recipes)
|
||||
13. [Testing](#13-testing)
|
||||
14. [Rules & constraints](#14-rules--constraints)
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture overview
|
||||
|
||||
```
|
||||
server/src/systemNotices/
|
||||
├── types.ts — TypeScript types (SystemNotice, NoticeCondition, …)
|
||||
├── registry.ts — Authoritative list of all notices (edit here to add/change/remove)
|
||||
├── conditions.ts — Condition evaluators + custom predicate registry
|
||||
└── service.ts — Queries DB, evaluates conditions, sorts, strips server-only fields
|
||||
|
||||
server/src/routes/systemNotices.ts — REST endpoints
|
||||
|
||||
client/src/store/systemNoticeStore.ts — Zustand store (fetch + optimistic dismiss)
|
||||
client/src/components/SystemNotices/
|
||||
├── SystemNoticeHost.tsx — Renders all three channels (modal / banner / toast)
|
||||
├── SystemNoticeModal.tsx — Modal renderer (pager, animations, keyboard nav)
|
||||
├── SystemNoticeBanner.tsx — Banner + toast renderers
|
||||
└── noticeActions.ts — Client-side action registry for action-kind CTAs
|
||||
|
||||
client/src/pages/Trips/noticeActions.ts — Example domain action registration
|
||||
```
|
||||
|
||||
There are **no database rows for notice definitions**. The registry is code-only. The database only stores which notices a user has dismissed.
|
||||
|
||||
---
|
||||
|
||||
## 2. Data flow
|
||||
|
||||
```
|
||||
1. User authenticates
|
||||
│
|
||||
▼
|
||||
2. authStore.loadUser() completes
|
||||
│
|
||||
▼
|
||||
3. SystemNoticeHost mounts → calls useSystemNoticeStore.fetch()
|
||||
│ (also triggered on cold page reload if store not yet loaded)
|
||||
▼
|
||||
4. GET /api/system-notices/active
|
||||
│
|
||||
▼
|
||||
5. service.getActiveNoticesFor(userId)
|
||||
├── reads user row (login_count, first_seen_version, role)
|
||||
├── counts user trips
|
||||
├── reads user_notice_dismissals
|
||||
├── filters SYSTEM_NOTICES:
|
||||
│ – not dismissed
|
||||
│ – not expired (expiresAt)
|
||||
│ – all conditions pass (AND logic)
|
||||
├── sorts by priority → severity → publishedAt (desc)
|
||||
└── strips server-only fields (conditions, publishedAt, expiresAt, priority)
|
||||
│
|
||||
▼
|
||||
6. Client receives SystemNoticeDTO[]
|
||||
│
|
||||
▼
|
||||
7. SystemNoticeHost partitions by display type
|
||||
├── modal → ModalRenderer (multi-page pager, slide transitions)
|
||||
├── banner → BannerRenderer (sticky top bar, max 2)
|
||||
└── toast → ToastRenderer (fires window.__addToast, auto-dismisses)
|
||||
│
|
||||
▼
|
||||
8. User dismisses → POST /api/system-notices/:id/dismiss
|
||||
├── Server: INSERT OR IGNORE into user_notice_dismissals
|
||||
└── Client: optimistic remove from store (retry once on failure)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Database schema
|
||||
|
||||
Added in **migration 101** (`server/src/db/migrations.ts`).
|
||||
|
||||
### `users` columns (added by migration 101)
|
||||
|
||||
| Column | Type | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `first_seen_version` | `TEXT` | `'0.0.0'` | App version at account creation. Used by `existingUserBeforeVersion` condition. Backfilled users get `'0.0.0'`. |
|
||||
| `login_count` | `INTEGER` | `0` | Incremented on each successful login. Used by `firstLogin` condition. |
|
||||
|
||||
### `user_notice_dismissals`
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `user_id` | `INTEGER` | FK → `users.id` CASCADE DELETE |
|
||||
| `notice_id` | `TEXT` | Matches `SystemNotice.id` from registry |
|
||||
| `dismissed_at` | `INTEGER` | Unix ms timestamp |
|
||||
|
||||
Primary key: `(user_id, notice_id)` — dismissals are idempotent.
|
||||
|
||||
---
|
||||
|
||||
## 4. The notice registry
|
||||
|
||||
**`server/src/systemNotices/registry.ts`** is the single source of truth. Add, change, or retire notices here.
|
||||
|
||||
```typescript
|
||||
export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
{
|
||||
id: 'my-notice', // ← globally unique, never reuse
|
||||
display: 'modal',
|
||||
severity: 'info',
|
||||
titleKey: 'system_notice.my_notice.title',
|
||||
bodyKey: 'system_notice.my_notice.body',
|
||||
dismissible: true,
|
||||
conditions: [{ kind: 'firstLogin' }],
|
||||
publishedAt: '2026-05-01T00:00:00Z',
|
||||
priority: 50,
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### The golden rule for IDs
|
||||
|
||||
**Never remove or renumber an entry. Never reuse an ID.**
|
||||
|
||||
Dismissals are stored in the database keyed by `id`. Removing an entry means dismissed users would see it again if you ever add a notice with the same ID. If a notice is no longer needed, add `expiresAt` to stop it from being shown — do not delete the entry.
|
||||
|
||||
---
|
||||
|
||||
## 5. Notice fields reference
|
||||
|
||||
### Required fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `string` | Globally unique, stable identifier. Use kebab-case, descriptive, version-scoped when appropriate (`v3-photos`, `welcome-v1`). Max recommended length: 40 chars. |
|
||||
| `display` | `'modal' \| 'banner' \| 'toast'` | How the notice is rendered. See [§7 Display types](#7-display-types). |
|
||||
| `severity` | `'info' \| 'warn' \| 'critical'` | Affects colour scheme and accessibility role. `critical` notices cannot be toasts. |
|
||||
| `titleKey` | `string` | i18n key for the title. |
|
||||
| `bodyKey` | `string` | i18n key for the body. Markdown supported in modals; plain text only in banners/toasts. |
|
||||
| `dismissible` | `boolean` | If `false`, the X button and ESC key are hidden/blocked. Use only for `critical` notices that require action before proceeding. |
|
||||
| `conditions` | `NoticeCondition[]` | Empty array (`[]`) means always shown (same as `[{ kind: 'always' }]`). All conditions must pass (AND logic). |
|
||||
| `publishedAt` | `string` | ISO 8601 date. Used as a tiebreaker in sorting. Set to the deployment date. |
|
||||
|
||||
### Optional fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `priority` | `number` | Higher number = shown first. Primary sort key. Default: `0`. |
|
||||
| `expiresAt` | `string` | ISO 8601 date. Notice is automatically hidden after this date. Preferred over deleting entries. |
|
||||
| `icon` | `string` | Lucide icon name (e.g. `'Sparkles'`, `'ImageOff'`). Shown in the modal's severity icon circle. Falls back to the severity default icon if absent or unrecognised. |
|
||||
| `bodyParams` | `Record<string, string>` | Interpolation parameters for `bodyKey`. Values replace `{key}` placeholders in the translated string. **Never hardcode version numbers or dates directly in translation strings — use this instead.** |
|
||||
| `media` | `NoticeMedia` | Image to display in the modal. See below. |
|
||||
| `highlights` | `Array<{ labelKey: string; iconName?: string }>` | Bullet-point feature list rendered below the body in modals. Each entry is a translation key + optional Lucide icon name. |
|
||||
| `cta` | `NoticeCta` | Primary action button. See [§8 CTAs](#8-ctas-call-to-action). |
|
||||
|
||||
### `NoticeMedia`
|
||||
|
||||
```typescript
|
||||
interface NoticeMedia {
|
||||
src: string; // URL or path
|
||||
srcDark?: string; // Optional dark-mode variant
|
||||
altKey: string; // i18n key for alt text
|
||||
placement?: 'hero' | 'inline'; // default: 'hero' (full-width above body)
|
||||
aspectRatio?: string; // CSS aspect-ratio value, default '16/9'
|
||||
}
|
||||
```
|
||||
|
||||
### Character limits
|
||||
|
||||
| Field | Modal | Banner | Toast |
|
||||
|---|---|---|---|
|
||||
| Title | ≤ 40 chars | ≤ 40 chars | ≤ 40 chars |
|
||||
| Body | ≤ 400 chars (markdown) | ≤ 140 chars (plain) | ≤ 80 chars (plain) |
|
||||
| CTA label | ≤ 20 chars, a verb | ≤ 20 chars | ≤ 20 chars |
|
||||
|
||||
---
|
||||
|
||||
## 6. Condition system
|
||||
|
||||
Conditions are evaluated **server-side** on every `GET /api/system-notices/active` call. The client never sees conditions — only the filtered result.
|
||||
|
||||
All conditions in `conditions[]` must pass (AND logic). To implement OR logic, create multiple notices with overlapping IDs is not possible — instead use a `custom` predicate with internal OR logic.
|
||||
|
||||
### Built-in conditions
|
||||
|
||||
#### `always`
|
||||
```typescript
|
||||
{ kind: 'always' }
|
||||
```
|
||||
Always passes. Equivalent to an empty `conditions` array.
|
||||
|
||||
---
|
||||
|
||||
#### `firstLogin`
|
||||
```typescript
|
||||
{ kind: 'firstLogin' }
|
||||
```
|
||||
Passes when `users.login_count <= 1`. The counter is incremented during login, so this fires on the first fetch after the very first login. Useful for onboarding notices.
|
||||
|
||||
---
|
||||
|
||||
#### `noTrips`
|
||||
```typescript
|
||||
{ kind: 'noTrips' }
|
||||
```
|
||||
Passes when the user has zero trips. Often combined with `firstLogin`.
|
||||
|
||||
---
|
||||
|
||||
#### `existingUserBeforeVersion`
|
||||
```typescript
|
||||
{ kind: 'existingUserBeforeVersion', version: '3.0.0' }
|
||||
```
|
||||
Passes when:
|
||||
- `users.first_seen_version < version` (user existed before this version)
|
||||
- AND the running app version `>= version` (the version has been deployed)
|
||||
|
||||
Backfilled/legacy users have `first_seen_version = '0.0.0'` and always pass the first condition. Use this for upgrade announcements targeting users who were around before a breaking change.
|
||||
|
||||
---
|
||||
|
||||
#### `dateWindow`
|
||||
```typescript
|
||||
{ kind: 'dateWindow', startsAt: '2026-06-01T00:00:00Z', endsAt: '2026-07-01T00:00:00Z' }
|
||||
```
|
||||
Passes when the current server time is inside `[startsAt, endsAt]`. `endsAt` is optional (open-ended). Use for campaigns, maintenance banners, and time-limited promotions.
|
||||
|
||||
---
|
||||
|
||||
#### `role`
|
||||
```typescript
|
||||
{ kind: 'role', roles: ['admin'] }
|
||||
// or both roles:
|
||||
{ kind: 'role', roles: ['admin', 'user'] }
|
||||
```
|
||||
Passes when the user's role is in the given list.
|
||||
|
||||
---
|
||||
|
||||
#### `addonEnabled`
|
||||
```typescript
|
||||
{ kind: 'addonEnabled', addonId: 'journey' }
|
||||
```
|
||||
Passes when the named addon is enabled in admin settings. Addon IDs are the string values in `server/src/addons.ts` (`ADDON_IDS`). Use this to gate notices that promote features behind an addon.
|
||||
|
||||
---
|
||||
|
||||
#### `custom`
|
||||
```typescript
|
||||
{ kind: 'custom', id: 'my-predicate-id' }
|
||||
```
|
||||
Delegates evaluation to a predicate registered server-side with `registerPredicate`. This is the escape hatch for logic not covered by the built-in conditions.
|
||||
|
||||
**Registering a custom predicate:**
|
||||
|
||||
```typescript
|
||||
// server/src/systemNotices/conditions.ts exports registerPredicate
|
||||
import { registerPredicate } from '../systemNotices/conditions.js';
|
||||
|
||||
registerPredicate('has-immich-configured', (ctx) => {
|
||||
// ctx.user = { login_count, first_seen_version, role, noTrips }
|
||||
// ctx.currentAppVersion = string
|
||||
// ctx.now = Date
|
||||
return someDbCheck(ctx.user);
|
||||
});
|
||||
```
|
||||
|
||||
Register predicates at application startup before the first `getActiveNoticesFor` call.
|
||||
|
||||
---
|
||||
|
||||
### Combining conditions (AND)
|
||||
|
||||
```typescript
|
||||
conditions: [
|
||||
{ kind: 'existingUserBeforeVersion', version: '3.0.0' },
|
||||
{ kind: 'addonEnabled', addonId: 'journey' },
|
||||
]
|
||||
// Only shows to pre-3.0 users AND only if the journey addon is enabled.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Display types
|
||||
|
||||
### `modal`
|
||||
|
||||
Full-screen overlay with backdrop. On mobile: bottom sheet with drag-to-dismiss. On desktop: centered card.
|
||||
|
||||
**Features:**
|
||||
- Markdown body (via `react-markdown` + `remark-gfm` + `rehype-sanitize`)
|
||||
- Optional hero or inline image
|
||||
- Optional highlights list (icon + label bullets)
|
||||
- Optional CTA button + "Not now" link
|
||||
- OK button when no CTA is defined
|
||||
- **Multi-page pager**: when multiple modal notices are active simultaneously, they are rendered as a paginated single modal with prev/next arrows, dot indicators, `N / M` counter, and keyboard arrow navigation
|
||||
- Slide transition between pages
|
||||
- ESC to dismiss all (if current notice is dismissible)
|
||||
- CTA and OK dismiss **all** active modal notices, not just the current page
|
||||
- "Not now" dismisses only the current page
|
||||
|
||||
**Non-dismissible modals** (`dismissible: false`): X button, ESC key, and pager navigation are all disabled until the user acts on the CTA. Use only for `critical` severity.
|
||||
|
||||
---
|
||||
|
||||
### `banner`
|
||||
|
||||
Sticky top bar below the navigation. Slides in with a translate-Y animation.
|
||||
|
||||
**Constraints:**
|
||||
- Maximum 2 banners shown simultaneously (the 2 highest-priority active banners)
|
||||
- Plain text only (no markdown)
|
||||
- RTL-aware left-border accent
|
||||
- Reports its height via a CSS variable `--banner-stack-h` for layout reflow
|
||||
|
||||
---
|
||||
|
||||
### `toast`
|
||||
|
||||
Fires the global `window.__addToast` toast system. Auto-dismisses after 6 s (`info`) or 9 s (`warn`). The notice is dismissed from the store after the toast expires.
|
||||
|
||||
**Constraints:**
|
||||
- `critical` severity is not allowed as a toast — the renderer logs a warning and auto-dismisses it instead
|
||||
- Plain text only
|
||||
- No interaction (no CTA rendered via toast)
|
||||
|
||||
---
|
||||
|
||||
## 8. CTAs (call to action)
|
||||
|
||||
A CTA renders as the primary blue button in modals and as an underline link in banners. There are two kinds.
|
||||
|
||||
### `nav` — navigate to a route
|
||||
|
||||
```typescript
|
||||
cta: {
|
||||
kind: 'nav',
|
||||
labelKey: 'system_notice.my_notice.cta_label',
|
||||
href: '/journey',
|
||||
}
|
||||
```
|
||||
|
||||
On click: navigates to `href` using React Router, then **dismisses all active modal notices** (or the current banner notice). The label is resolved through the i18n system.
|
||||
|
||||
---
|
||||
|
||||
### `action` — run a registered client-side handler
|
||||
|
||||
```typescript
|
||||
cta: {
|
||||
kind: 'action',
|
||||
labelKey: 'system_notice.my_notice.cta_label',
|
||||
actionId: 'open:trip-create',
|
||||
dismissOnAction: true, // default true — set false to keep notice open after action
|
||||
}
|
||||
```
|
||||
|
||||
On click: looks up `actionId` in the client-side action registry and calls the handler, then **dismisses all active modal notices**.
|
||||
|
||||
**To add a new action:**
|
||||
|
||||
1. Create (or extend) a `noticeActions.ts` file in the relevant feature directory:
|
||||
|
||||
```typescript
|
||||
// client/src/pages/MyFeature/noticeActions.ts
|
||||
import { registerNoticeAction } from '../../components/SystemNotices/noticeActions.js';
|
||||
|
||||
registerNoticeAction('open:my-feature', ({ navigate }) => {
|
||||
navigate('/my-feature?from=notice');
|
||||
});
|
||||
```
|
||||
|
||||
2. Import it as a side-effect in `client/src/App.tsx`:
|
||||
|
||||
```typescript
|
||||
import './pages/MyFeature/noticeActions.js'
|
||||
```
|
||||
|
||||
3. The registry integrity test (`server/tests/unit/systemNotices/registry.test.ts`) automatically scans all `noticeActions.ts` files and verifies that every `actionId` in the registry is registered. The test will fail if you add an `actionId` to the registry without registering it on the client.
|
||||
|
||||
**Action handler signature:**
|
||||
|
||||
```typescript
|
||||
(ctx: NoticeActionContext) => void | Promise<void>
|
||||
|
||||
interface NoticeActionContext {
|
||||
navigate: NavigateFunction; // React Router navigate function
|
||||
}
|
||||
```
|
||||
|
||||
### Dismiss behaviour summary
|
||||
|
||||
| Trigger | What is dismissed |
|
||||
|---|---|
|
||||
| X button (modal) | All active modal notices |
|
||||
| ESC key | All active modal notices (if current is dismissible) |
|
||||
| CTA button | All active modal notices |
|
||||
| OK button (no CTA) | All active modal notices |
|
||||
| "Not now" link | Current page only |
|
||||
| Banner dismiss (X) | That banner only |
|
||||
| Backdrop click (modal) | Current page only |
|
||||
| Swipe down (mobile) | Current page only |
|
||||
| Toast expires | That toast only |
|
||||
|
||||
---
|
||||
|
||||
## 9. i18n — translation keys
|
||||
|
||||
Every notice field that is user-visible (`titleKey`, `bodyKey`, CTA `labelKey`, highlight `labelKey`, media `altKey`) is an i18n key resolved through `useTranslation().t()`. The key string is what gets stored in the registry; the display value lives in the translation files.
|
||||
|
||||
**Translation files location:** `client/src/i18n/translations/` (15 files: `en`, `de`, `fr`, `es`, `it`, `nl`, `pl`, `cs`, `hu`, `ru`, `zh`, `zhTw`, `ar`, `br`, `id`)
|
||||
|
||||
### Key naming convention
|
||||
|
||||
```
|
||||
system_notice.<notice_id_snake>.<field>
|
||||
```
|
||||
|
||||
Examples:
|
||||
```
|
||||
system_notice.welcome_v1.title
|
||||
system_notice.welcome_v1.body
|
||||
system_notice.welcome_v1.cta_label
|
||||
system_notice.welcome_v1.highlight_plan
|
||||
system_notice.welcome_v1.hero_alt
|
||||
```
|
||||
|
||||
### Adding keys
|
||||
|
||||
Add the English key to `client/src/i18n/translations/en.ts` first, then replicate to the other 14 files. Group related notice keys together with a comment:
|
||||
|
||||
```typescript
|
||||
// System notices — my feature
|
||||
'system_notice.my_notice.title': 'My feature is here',
|
||||
'system_notice.my_notice.body': 'Here is what changed.',
|
||||
'system_notice.my_notice.cta_label': 'Explore',
|
||||
```
|
||||
|
||||
### `bodyParams` interpolation
|
||||
|
||||
For values that vary at runtime (version numbers, dates, counts), use `{placeholder}` syntax in the translation string and pass `bodyParams` in the registry entry:
|
||||
|
||||
```typescript
|
||||
// In registry:
|
||||
bodyKey: 'system_notice.my_notice.body',
|
||||
bodyParams: { version: '3.1.0', date: '1 May 2026' },
|
||||
|
||||
// In en.ts:
|
||||
'system_notice.my_notice.body': 'TREK {version} was released on {date}.',
|
||||
```
|
||||
|
||||
**Never hardcode dynamic values directly in translation strings.** The interpolation runs client-side in `ModalRenderer` before rendering.
|
||||
|
||||
### Multiline bodies (modals only)
|
||||
|
||||
Use `\n\n` (escaped, not literal newlines) for paragraph breaks in modal body strings:
|
||||
|
||||
```typescript
|
||||
'system_notice.my_notice.body': 'First paragraph.\n\nSecond paragraph.',
|
||||
```
|
||||
|
||||
Literal newlines in single-quoted TypeScript strings cause a parse error.
|
||||
|
||||
### Pager i18n keys
|
||||
|
||||
The pager UI uses its own keys (already present in all 15 files):
|
||||
|
||||
```
|
||||
system_notice.pager.prev → "Previous notice"
|
||||
system_notice.pager.next → "Next notice"
|
||||
system_notice.pager.counter → "{current} / {total}"
|
||||
system_notice.pager.goto → "Go to notice {n}"
|
||||
system_notice.pager.position → "Notice {current} of {total}" (aria-live)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Client store & dismissal
|
||||
|
||||
`client/src/store/systemNoticeStore.ts` (Zustand, no persistence).
|
||||
|
||||
| Action | Behaviour |
|
||||
|---|---|
|
||||
| `fetch()` | `GET /api/system-notices/active`. Fails silently (non-critical). Sets `loaded = true` regardless. |
|
||||
| `dismiss(id)` | Optimistic: removes notice from store immediately. POSTs to `/api/system-notices/{id}/dismiss` in background with one retry on failure. |
|
||||
|
||||
`SystemNoticeHost` triggers `fetch()` on mount if `loaded === false`. Auth store also triggers it after login, so on a fresh login the fetch happens exactly once.
|
||||
|
||||
---
|
||||
|
||||
## 11. Sorting & priority
|
||||
|
||||
Notices are sorted before being sent to the client. The sort order is:
|
||||
|
||||
1. **`priority`** (descending) — primary key. Higher number appears first.
|
||||
2. **`severity`** (descending) — tiebreaker: `critical` (2) > `warn` (1) > `info` (0).
|
||||
3. **`publishedAt`** (descending) — final tiebreaker: more recent notices first.
|
||||
|
||||
This means `priority` always wins over severity. Assign priorities deliberately so the intended reading order is preserved when multiple notices are active simultaneously.
|
||||
|
||||
Current priority allocations in the registry:
|
||||
|
||||
| Range | Use |
|
||||
|---|---|
|
||||
| 100 | Onboarding / first-login |
|
||||
| 80–90 | Major version upgrade notices |
|
||||
| 50–70 | Feature announcements |
|
||||
| 10–40 | Campaigns, banners |
|
||||
| 0 (default) | Miscellaneous |
|
||||
|
||||
---
|
||||
|
||||
## 12. How-to recipes
|
||||
|
||||
### Add a new modal notice
|
||||
|
||||
1. **Registry** — add an entry to `SYSTEM_NOTICES` in `server/src/systemNotices/registry.ts`:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'my-feature-v2',
|
||||
display: 'modal',
|
||||
severity: 'info',
|
||||
icon: 'Zap',
|
||||
titleKey: 'system_notice.my_feature_v2.title',
|
||||
bodyKey: 'system_notice.my_feature_v2.body',
|
||||
highlights: [
|
||||
{ labelKey: 'system_notice.my_feature_v2.highlight_one', iconName: 'Check' },
|
||||
],
|
||||
cta: {
|
||||
kind: 'nav',
|
||||
labelKey: 'system_notice.my_feature_v2.cta_label',
|
||||
href: '/my-feature',
|
||||
},
|
||||
dismissible: true,
|
||||
conditions: [{ kind: 'existingUserBeforeVersion', version: '2.0.0' }],
|
||||
publishedAt: '2026-06-01T00:00:00Z',
|
||||
priority: 60,
|
||||
},
|
||||
```
|
||||
|
||||
2. **i18n** — add keys to `client/src/i18n/translations/en.ts` and the 14 other language files.
|
||||
|
||||
3. **Test** — run `cd server && npx vitest run tests/unit/systemNotices/` to verify registry integrity.
|
||||
|
||||
---
|
||||
|
||||
### Add a notice with an action CTA
|
||||
|
||||
1. Create the action handler in the relevant feature directory:
|
||||
|
||||
```typescript
|
||||
// client/src/pages/MyFeature/noticeActions.ts
|
||||
import { registerNoticeAction } from '../../components/SystemNotices/noticeActions.js';
|
||||
|
||||
registerNoticeAction('open:my-feature-dialog', ({ navigate }) => {
|
||||
navigate('/my-feature?dialog=welcome');
|
||||
});
|
||||
```
|
||||
|
||||
2. Import it in `client/src/App.tsx`:
|
||||
|
||||
```typescript
|
||||
import './pages/MyFeature/noticeActions.js'
|
||||
```
|
||||
|
||||
3. Reference the `actionId` in the registry:
|
||||
|
||||
```typescript
|
||||
cta: {
|
||||
kind: 'action',
|
||||
labelKey: 'system_notice.my_notice.cta_label',
|
||||
actionId: 'open:my-feature-dialog',
|
||||
},
|
||||
```
|
||||
|
||||
The registry integrity test will catch any `actionId` that appears in the registry but lacks a `registerNoticeAction` call.
|
||||
|
||||
---
|
||||
|
||||
### Retire a notice (stop showing it)
|
||||
|
||||
**Do not delete the entry.** Set `expiresAt`:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'old-campaign',
|
||||
// ... all existing fields unchanged ...
|
||||
expiresAt: '2026-07-01T00:00:00Z',
|
||||
}
|
||||
```
|
||||
|
||||
After the expiry date the service filters it out automatically. The database row for dismissed users remains harmless.
|
||||
|
||||
---
|
||||
|
||||
### Show a notice only during a campaign window
|
||||
|
||||
Combine `dateWindow` with any other targeting conditions:
|
||||
|
||||
```typescript
|
||||
conditions: [
|
||||
{ kind: 'dateWindow', startsAt: '2026-06-15T00:00:00Z', endsAt: '2026-06-30T23:59:59Z' },
|
||||
{ kind: 'role', roles: ['admin'] },
|
||||
],
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Show a notice only if an addon is enabled
|
||||
|
||||
```typescript
|
||||
conditions: [
|
||||
{ kind: 'addonEnabled', addonId: 'journey' },
|
||||
],
|
||||
```
|
||||
|
||||
Addon IDs are the string values in `server/src/addons.ts` → `ADDON_IDS`.
|
||||
|
||||
---
|
||||
|
||||
### Add a custom condition
|
||||
|
||||
```typescript
|
||||
// server/src/startup.ts (or wherever your bootstrap code runs)
|
||||
import { registerPredicate } from './systemNotices/conditions.js';
|
||||
|
||||
registerPredicate('has-no-profile-photo', (ctx) => {
|
||||
const row = db.prepare('SELECT avatar FROM users WHERE id = ?').get(ctx.user.id);
|
||||
return !row?.avatar;
|
||||
});
|
||||
```
|
||||
|
||||
Then reference it in the registry:
|
||||
|
||||
```typescript
|
||||
conditions: [{ kind: 'custom', id: 'has-no-profile-photo' }],
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create a multipage upgrade announcement
|
||||
|
||||
Give multiple notices the same `conditions` and adjacent `priority` values. The pager groups all active modal notices together automatically — no extra wiring required.
|
||||
|
||||
```typescript
|
||||
// Page 1 — breaking change (higher priority, warn severity)
|
||||
{ id: 'v4-breaking', priority: 90, severity: 'warn', conditions: [{ kind: 'existingUserBeforeVersion', version: '4.0.0' }], ... },
|
||||
|
||||
// Page 2 — new feature (lower priority, info severity)
|
||||
{ id: 'v4-feature', priority: 80, severity: 'info', conditions: [{ kind: 'existingUserBeforeVersion', version: '4.0.0' }], ... },
|
||||
```
|
||||
|
||||
Users who have already dismissed page 1 will only see page 2 on their next session.
|
||||
|
||||
---
|
||||
|
||||
## 13. Testing
|
||||
|
||||
### Server unit tests
|
||||
|
||||
**`server/tests/unit/systemNotices/conditions.test.ts`**
|
||||
|
||||
Tests each condition kind in isolation using `evaluate()` directly. No DB required.
|
||||
|
||||
**`server/tests/unit/systemNotices/registry.test.ts`**
|
||||
|
||||
Validates registry integrity:
|
||||
- No duplicate `id` values
|
||||
- All `action` CTA `actionId`s have a corresponding `registerNoticeAction()` call in the client source (scanned via regex — no JSON file needed)
|
||||
- All `publishedAt` values parse as valid ISO dates
|
||||
|
||||
Run: `cd server && npx vitest run tests/unit/systemNotices/`
|
||||
|
||||
**`server/tests/integration/systemNotices.test.ts`**
|
||||
|
||||
Integration tests against a real in-memory SQLite database:
|
||||
- `GET /api/system-notices/active` returns 401 without auth, returns correct notices per user state
|
||||
- `POST /api/system-notices/:id/dismiss` stores the dismissal and filters on subsequent requests
|
||||
- Dismissing an unknown ID returns 404
|
||||
|
||||
Run: `cd server && npx vitest run tests/integration/systemNotices.test.ts`
|
||||
|
||||
---
|
||||
|
||||
### Client unit tests
|
||||
|
||||
**`client/src/components/SystemNotices/SystemNoticeModal.test.tsx`**
|
||||
|
||||
Tests `ModalRenderer` with fake timers (`vi.useFakeTimers()`) and MSW for the dismiss endpoint. Key helpers:
|
||||
|
||||
```typescript
|
||||
// Flush the 500 ms grace delay that gates the modal's visible state
|
||||
async function flushGraceDelay() {
|
||||
await act(async () => { vi.runAllTimers(); });
|
||||
}
|
||||
|
||||
// Minimal notice factory
|
||||
function makeNotice(overrides?: Partial<SystemNoticeDTO>): SystemNoticeDTO
|
||||
```
|
||||
|
||||
Covered cases (FE-SN-MODAL-001 to 018):
|
||||
- Grace delay before visibility
|
||||
- Dismiss button, X button, ESC key
|
||||
- Non-dismissible notices (all affordances blocked)
|
||||
- CTA nav button — dismisses all notices
|
||||
- Body param interpolation
|
||||
- Pager: counter, dots, prev/next buttons, keyboard arrows, dot click, non-dismissible lock
|
||||
- Dismiss-does-not-skip regression
|
||||
- X and ESC dismiss all in multipage scenario
|
||||
- Last notice close
|
||||
|
||||
Run: `cd client && npm run test -- SystemNoticeModal`
|
||||
|
||||
---
|
||||
|
||||
### Running all notice tests
|
||||
|
||||
```bash
|
||||
cd server && npx vitest run tests/unit/systemNotices/ tests/integration/systemNotices.test.ts
|
||||
cd client && npm run test -- SystemNoticeModal
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Rules & constraints
|
||||
|
||||
| Rule | Reason |
|
||||
|---|---|
|
||||
| Never delete or reuse a notice `id` | Dismissal records are keyed by `id`. Deletion causes dismissed users to see the notice again. |
|
||||
| Never use literal newlines in translation strings | Single-quoted TS strings with literal newlines cause esbuild parse errors. Use `\n\n` (escaped). |
|
||||
| Never hardcode version numbers or dates in translation strings | Use `bodyParams` so strings stay translatable without retranslation per release. |
|
||||
| `critical` severity must have `dismissible: false` | `critical` toasts are auto-dismissed with a warning; a dismissible critical modal is inconsistent UX. |
|
||||
| `critical` must not use `display: 'toast'` | The toast renderer logs a warning and auto-dismisses critical toasts rather than showing them. |
|
||||
| CTA labels ≤ 20 chars, sentence case, a verb | Consistent button copy across the app. |
|
||||
| Priorities must be set explicitly for upgrade notices | Adjacent notices form a multipage group; ordering matters for the reading flow. |
|
||||
| `action` CTA `actionId` must be registered client-side | The registry integrity test enforces this. Add both the registry entry and the `registerNoticeAction` call in the same PR. |
|
||||
| `expiresAt` over deletion for retiring notices | See above. |
|
||||
Generated
+12
-13
@@ -24,6 +24,7 @@
|
||||
"nodemailer": "^8.0.5",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"semver": "^7.7.4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2",
|
||||
"undici": "^7.0.0",
|
||||
@@ -45,6 +46,7 @@
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@types/uuid": "^10.0.0",
|
||||
@@ -1590,7 +1592,6 @@
|
||||
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^4.17.33",
|
||||
@@ -1721,6 +1722,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/semver": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
|
||||
"integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
@@ -2152,7 +2160,6 @@
|
||||
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
|
||||
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"bare-abort-controller": "*"
|
||||
},
|
||||
@@ -3164,7 +3171,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
@@ -3668,11 +3674,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.12",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
|
||||
"integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
|
||||
"version": "4.12.14",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
|
||||
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -5730,7 +5735,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -5805,7 +5809,6 @@
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -5961,7 +5964,6 @@
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -6078,7 +6080,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -6092,7 +6093,6 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -6331,7 +6331,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
+3
-1
@@ -26,12 +26,13 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.1.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"undici": "^7.0.0",
|
||||
"nodemailer": "^8.0.5",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"semver": "^7.7.4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2",
|
||||
"undici": "^7.0.0",
|
||||
"unzipper": "^0.12.3",
|
||||
"uuid": "^9.0.0",
|
||||
"ws": "^8.19.0",
|
||||
@@ -54,6 +55,7 @@
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@types/uuid": "^10.0.0",
|
||||
|
||||
@@ -6,6 +6,7 @@ export const ADDON_IDS = {
|
||||
VACAY: 'vacay',
|
||||
ATLAS: 'atlas',
|
||||
COLLAB: 'collab',
|
||||
JOURNEY: 'journey',
|
||||
} as const;
|
||||
|
||||
export type AddonId = typeof ADDON_IDS[keyof typeof ADDON_IDS];
|
||||
|
||||
+10
-1
@@ -42,9 +42,13 @@ import shareRoutes from './routes/share';
|
||||
import journeyRoutes from './routes/journey';
|
||||
import journeyPublicRoutes from './routes/journeyPublic';
|
||||
import publicConfigRoutes from './routes/publicConfig';
|
||||
import systemNoticesRoutes from './routes/systemNotices';
|
||||
import { mcpHandler } from './mcp';
|
||||
import { Addon } from './types';
|
||||
import { getPhotoProviderConfig } from './services/memories/helpersService';
|
||||
import { getCollabFeatures } from './services/adminService';
|
||||
import { isAddonEnabled } from './services/adminService';
|
||||
import { ADDON_IDS } from './addons';
|
||||
|
||||
export function createApp(): express.Application {
|
||||
const app = express();
|
||||
@@ -236,6 +240,7 @@ export function createApp(): express.Application {
|
||||
}
|
||||
|
||||
res.json({
|
||||
collabFeatures: getCollabFeatures(),
|
||||
addons: [
|
||||
...addons.map(a => ({ ...a, enabled: !!a.enabled })),
|
||||
...providers.map(p => ({
|
||||
@@ -265,13 +270,17 @@ export function createApp(): express.Application {
|
||||
// Addon routes
|
||||
app.use('/api/addons/vacay', vacayRoutes);
|
||||
app.use('/api/addons/atlas', atlasRoutes);
|
||||
app.use('/api/journeys', journeyRoutes);
|
||||
app.use('/api/journeys', (req, res, next) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return res.status(404).json({ error: 'Journey addon is not enabled' });
|
||||
next();
|
||||
}, journeyRoutes);
|
||||
app.use('/api/public/journey', journeyPublicRoutes);
|
||||
app.use('/api/integrations/memories', memoriesRoutes);
|
||||
app.use('/api/photos', photoRoutes);
|
||||
app.use('/api/maps', mapsRoutes);
|
||||
app.use('/api/weather', weatherRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/system-notices', systemNoticesRoutes);
|
||||
app.use('/api/backup', backupRoutes);
|
||||
app.use('/api/notifications', notificationRoutes);
|
||||
app.use('/api', shareRoutes);
|
||||
|
||||
@@ -1605,6 +1605,35 @@ function runMigrations(db: Database.Database): void {
|
||||
CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at);
|
||||
`);
|
||||
},
|
||||
|
||||
// Migration 101: Enable naver_list_import by default
|
||||
() => {
|
||||
db.prepare("UPDATE addons SET enabled = 1 WHERE id = 'naver_list_import'").run();
|
||||
},
|
||||
|
||||
// Migration 102: Add check_in_end column for check-in time ranges
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE day_accommodations ADD COLUMN check_in_end TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
},
|
||||
// Migration 103: System notices — user tracking columns + dismissals table
|
||||
() => {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN first_seen_version TEXT NOT NULL DEFAULT '0.0.0'`);
|
||||
db.exec(`ALTER TABLE users ADD COLUMN login_count INTEGER NOT NULL DEFAULT 0`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_notice_dismissals (
|
||||
user_id INTEGER NOT NULL,
|
||||
notice_id TEXT NOT NULL,
|
||||
dismissed_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, notice_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
},
|
||||
// Migration 104: Passphrase support for Synology shared-album links (#689)
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE trip_album_links ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
try { db.exec('ALTER TABLE trek_photos ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -334,6 +334,7 @@ function createTables(db: Database.Database): void {
|
||||
start_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
|
||||
end_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
|
||||
check_in TEXT,
|
||||
check_in_end TEXT,
|
||||
check_out TEXT,
|
||||
confirmation TEXT,
|
||||
notes TEXT,
|
||||
|
||||
@@ -92,7 +92,7 @@ function seedAddons(db: Database.Database): void {
|
||||
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', enabled: 1, sort_order: 10 },
|
||||
{ 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: 'naver_list_import', name: 'Naver List Import', description: 'Import places from shared Naver Maps lists', type: 'trip', icon: 'Link2', enabled: 0, sort_order: 13 },
|
||||
{ id: 'naver_list_import', name: 'Naver List Import', description: 'Import places from shared Naver Maps lists', type: 'trip', icon: 'Link2', enabled: 1, sort_order: 13 },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { authenticate, adminOnly } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
|
||||
import * as svc from '../services/adminService';
|
||||
import { getAdminUserDefaults, setAdminUserDefaults } from '../services/settingsService';
|
||||
import { invalidateMcpSessions } from '../mcp';
|
||||
import { getPreferencesMatrix, setAdminPreferences } from '../services/notificationPreferencesService';
|
||||
|
||||
@@ -200,6 +201,24 @@ router.put('/bag-tracking', (req: Request, res: Response) => {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Collab Features ───────────────────────────────────────────────────────
|
||||
|
||||
router.get('/collab-features', (_req: Request, res: Response) => {
|
||||
res.json(svc.getCollabFeatures());
|
||||
});
|
||||
|
||||
router.put('/collab-features', (req: Request, res: Response) => {
|
||||
const result = svc.updateCollabFeatures(req.body);
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.collab_features',
|
||||
ip: getClientIp(req),
|
||||
details: result,
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Packing Templates ──────────────────────────────────────────────────────
|
||||
|
||||
router.get('/packing-templates', (_req: Request, res: Response) => {
|
||||
@@ -346,6 +365,31 @@ router.post('/rotate-jwt-secret', (req: Request, res: Response) => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Default User Settings ──────────────────────────────────────────────────────
|
||||
|
||||
router.get('/default-user-settings', (_req: Request, res: Response) => {
|
||||
res.json(getAdminUserDefaults());
|
||||
});
|
||||
|
||||
router.put('/default-user-settings', (req: Request, res: Response) => {
|
||||
if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) {
|
||||
return res.status(400).json({ error: 'Object body required' });
|
||||
}
|
||||
try {
|
||||
setAdminUserDefaults(req.body);
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.default_user_settings_update',
|
||||
ip: getClientIp(req),
|
||||
details: req.body,
|
||||
});
|
||||
res.json(getAdminUserDefaults());
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Dev-only: test notification endpoints ──────────────────────────────────────
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const { send } = require('../services/notificationService');
|
||||
|
||||
@@ -73,7 +73,7 @@ accommodationsRouter.post('/', authenticate, requireTripAccess, (req: Request, r
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { tripId } = req.params;
|
||||
const { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes } = req.body;
|
||||
const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = req.body;
|
||||
|
||||
if (!place_id || !start_day_id || !end_day_id) {
|
||||
return res.status(400).json({ error: 'place_id, start_day_id, and end_day_id are required' });
|
||||
@@ -82,7 +82,7 @@ accommodationsRouter.post('/', authenticate, requireTripAccess, (req: Request, r
|
||||
const errors = dayService.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
|
||||
if (errors.length > 0) return res.status(404).json({ error: errors[0].message });
|
||||
|
||||
const accommodation = dayService.createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
|
||||
const accommodation = dayService.createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
|
||||
res.status(201).json({ accommodation });
|
||||
broadcast(tripId, 'accommodation:created', { accommodation }, req.headers['x-socket-id'] as string);
|
||||
broadcast(tripId, 'reservation:created', {}, req.headers['x-socket-id'] as string);
|
||||
@@ -98,12 +98,12 @@ accommodationsRouter.put('/:id', authenticate, requireTripAccess, (req: Request,
|
||||
const existing = dayService.getAccommodation(id, tripId);
|
||||
if (!existing) return res.status(404).json({ error: 'Accommodation not found' });
|
||||
|
||||
const { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes } = req.body;
|
||||
const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = req.body;
|
||||
|
||||
const errors = dayService.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
|
||||
if (errors.length > 0) return res.status(404).json({ error: errors[0].message });
|
||||
|
||||
const accommodation = dayService.updateAccommodation(id, existing, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
|
||||
const accommodation = dayService.updateAccommodation(id, existing, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
|
||||
res.json({ accommodation });
|
||||
broadcast(tripId, 'accommodation:updated', { accommodation }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
@@ -60,16 +60,12 @@ router.get('/browse', authenticate, async (req: Request, res: Response) => {
|
||||
|
||||
router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { from, to, size } = req.body;
|
||||
const { from, to, size, page } = req.body;
|
||||
const pageNum = Math.max(1, Number(page) || 1);
|
||||
const pageSize = Math.min(Number(size) || 50, 200);
|
||||
const allAssets: any[] = [];
|
||||
for (let page = 1; page <= 20; page++) {
|
||||
const result = await searchPhotos(authReq.user.id, from, to, page, pageSize);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
if (result.assets) allAssets.push(...result.assets);
|
||||
if (!result.hasMore) break;
|
||||
}
|
||||
res.json({ assets: allAssets });
|
||||
const result = await searchPhotos(authReq.user.id, from, to, pageNum, pageSize);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ assets: result.assets || [], hasMore: !!result.hasMore });
|
||||
});
|
||||
|
||||
// ── Asset Details ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -80,7 +80,8 @@ router.get('/albums', authenticate, async (req: Request, res: Response) => {
|
||||
|
||||
router.get('/albums/:albumId/photos', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
handleServiceResult(res, await getSynologyAlbumPhotos(authReq.user.id, req.params.albumId));
|
||||
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
|
||||
handleServiceResult(res, await getSynologyAlbumPhotos(authReq.user.id, req.params.albumId, passphrase));
|
||||
});
|
||||
|
||||
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
|
||||
@@ -100,8 +101,8 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
const page = _parseNumberBodyField(body.page, 1) - 1;
|
||||
let limit = _parseNumberBodyField(body.limit, 100);
|
||||
const size = _parseNumberBodyField(body.size, 0);
|
||||
if(page > 0) offset = page*limit;
|
||||
if(size > 0) limit = size;
|
||||
if (size > 0) limit = size;
|
||||
if (page > 0) offset = page * limit;
|
||||
|
||||
handleServiceResult(res, await searchSynologyPhotos(
|
||||
authReq.user.id,
|
||||
@@ -115,12 +116,13 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
router.get('/assets/:tripId/:photoId/:ownerId/info', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, photoId, ownerId } = req.params;
|
||||
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
|
||||
|
||||
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
|
||||
handleServiceResult(res, fail('You don\'t have access to this photo', 403));
|
||||
}
|
||||
else {
|
||||
handleServiceResult(res, await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId)));
|
||||
handleServiceResult(res, await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId), passphrase));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -130,6 +132,7 @@ router.get('/assets/:tripId/:photoId/:ownerId/:kind', authenticate, async (req:
|
||||
const VALID_SIZES = ['sm', 'm', 'xl'] as const;
|
||||
const rawSize = String(req.query.size ?? 'sm');
|
||||
const size = VALID_SIZES.includes(rawSize as any) ? rawSize : 'sm';
|
||||
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
|
||||
|
||||
if (kind !== 'thumbnail' && kind !== 'original') {
|
||||
return handleServiceResult(res, fail('Invalid asset kind', 400));
|
||||
@@ -139,7 +142,7 @@ router.get('/assets/:tripId/:photoId/:ownerId/:kind', authenticate, async (req:
|
||||
handleServiceResult(res, fail('You don\'t have access to this photo', 403));
|
||||
}
|
||||
else{
|
||||
await streamSynologyAsset(res, authReq.user.id, Number(ownerId), photoId, kind as 'thumbnail' | 'original', String(size));
|
||||
await streamSynologyAsset(res, authReq.user.id, Number(ownerId), photoId, kind as 'thumbnail' | 'original', String(size), passphrase);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@@ -84,7 +84,8 @@ router.get('/unified/trips/:tripId/album-links', authenticate, (req: Request, re
|
||||
router.post('/unified/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const result = createTripAlbumLink(tripId, authReq.user.id, req.body?.provider, req.body?.album_id, req.body?.album_name);
|
||||
const passphrase = req.body?.passphrase ? String(req.body.passphrase) : undefined;
|
||||
const result = createTripAlbumLink(tripId, authReq.user.id, req.body?.provider, req.body?.album_id, req.body?.album_name, passphrase);
|
||||
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import { requireTripAccess } from '../middleware/tripAccess';
|
||||
import { broadcast } from '../websocket';
|
||||
import { validateStringLengths } from '../middleware/validate';
|
||||
import { checkPermission } from '../services/permissions';
|
||||
import { isAddonEnabled } from '../services/adminService';
|
||||
import { AuthRequest } from '../types';
|
||||
import {
|
||||
listPlaces,
|
||||
@@ -135,10 +134,6 @@ router.post('/import/naver-list', authenticate, requireTripAccess, async (req: R
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
if (!isAddonEnabled('naver_list_import')) {
|
||||
return res.status(403).json({ error: 'Naver list import addon is disabled' });
|
||||
}
|
||||
|
||||
const { tripId } = req.params;
|
||||
const { url } = req.body;
|
||||
if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' });
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../middleware/auth.js';
|
||||
import { getActiveNoticesFor, dismissNotice } from '../systemNotices/service.js';
|
||||
import type { AuthRequest } from '../types.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/system-notices/active
|
||||
// Returns notices active for the authenticated user.
|
||||
router.get('/active', authenticate, (req, res) => {
|
||||
const userId = (req as AuthRequest).user!.id;
|
||||
const notices = getActiveNoticesFor(userId);
|
||||
res.json(notices);
|
||||
});
|
||||
|
||||
// POST /api/system-notices/:id/dismiss
|
||||
// Marks a notice as dismissed for the authenticated user. Idempotent.
|
||||
router.post('/:id/dismiss', authenticate, (req, res) => {
|
||||
const userId = (req as AuthRequest).user!.id;
|
||||
const noticeId = req.params.id;
|
||||
const ok = dismissNotice(userId, noticeId);
|
||||
if (!ok) {
|
||||
res.status(404).json({ error: 'NOTICE_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -459,6 +459,31 @@ export function updateBagTracking(enabled: boolean) {
|
||||
return { enabled: !!enabled };
|
||||
}
|
||||
|
||||
// ── Collab Features ───────────────────────────────────────────────────────
|
||||
|
||||
const COLLAB_FEATURE_KEYS = ['collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled'] as const;
|
||||
|
||||
export function getCollabFeatures() {
|
||||
const rows = db.prepare("SELECT key, value FROM app_settings WHERE key IN ('collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')").all() as { key: string; value: string }[];
|
||||
const map: Record<string, string> = {};
|
||||
for (const r of rows) map[r.key] = r.value;
|
||||
return {
|
||||
chat: map['collab_chat_enabled'] !== 'false',
|
||||
notes: map['collab_notes_enabled'] !== 'false',
|
||||
polls: map['collab_polls_enabled'] !== 'false',
|
||||
whatsnext: map['collab_whatsnext_enabled'] !== 'false',
|
||||
};
|
||||
}
|
||||
|
||||
export function updateCollabFeatures(features: { chat?: boolean; notes?: boolean; polls?: boolean; whatsnext?: boolean }) {
|
||||
const mapping: Record<string, string> = { chat: 'collab_chat_enabled', notes: 'collab_notes_enabled', polls: 'collab_polls_enabled', whatsnext: 'collab_whatsnext_enabled' };
|
||||
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
|
||||
for (const [feat, key] of Object.entries(mapping)) {
|
||||
if (features[feat] !== undefined) stmt.run(key, features[feat] ? 'true' : 'false');
|
||||
}
|
||||
return getCollabFeatures();
|
||||
}
|
||||
|
||||
// ── Packing Templates ──────────────────────────────────────────────────────
|
||||
|
||||
export function listPackingTemplates() {
|
||||
|
||||
@@ -334,8 +334,8 @@ export function registerUser(body: {
|
||||
|
||||
try {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)'
|
||||
).run(username, email, password_hash, role);
|
||||
'INSERT INTO users (username, email, password_hash, role, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, 0)'
|
||||
).run(username, email, password_hash, role, process.env.APP_VERSION || '0.0.0');
|
||||
|
||||
const user = { id: result.lastInsertRowid, username, email, role, avatar: null, mfa_enabled: false };
|
||||
const token = generateToken(user);
|
||||
@@ -408,7 +408,7 @@ export function loginUser(body: {
|
||||
return { mfa_required: true, mfa_token };
|
||||
}
|
||||
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id);
|
||||
const token = generateToken(user);
|
||||
const userSafe = stripUserForClient(user) as Record<string, unknown>;
|
||||
|
||||
@@ -972,7 +972,7 @@ export function verifyMfaLogin(body: {
|
||||
user.id
|
||||
);
|
||||
}
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id);
|
||||
const sessionToken = generateToken(user);
|
||||
const userSafe = stripUserForClient(user) as Record<string, unknown>;
|
||||
return {
|
||||
|
||||
@@ -170,6 +170,7 @@ export interface DayAccommodation {
|
||||
start_day_id: number;
|
||||
end_day_id: number;
|
||||
check_in: string | null;
|
||||
check_in_end: string | null;
|
||||
check_out: string | null;
|
||||
confirmation: string | null;
|
||||
notes: string | null;
|
||||
@@ -220,17 +221,18 @@ interface CreateAccommodationData {
|
||||
start_day_id: number;
|
||||
end_day_id: number;
|
||||
check_in?: string;
|
||||
check_in_end?: string;
|
||||
check_out?: string;
|
||||
confirmation?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export function createAccommodation(tripId: string | number, data: CreateAccommodationData) {
|
||||
const { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes } = data;
|
||||
const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = data;
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(tripId, place_id, start_day_id, end_day_id, check_in || null, check_out || null, confirmation || null, notes || null);
|
||||
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(tripId, place_id, start_day_id, end_day_id, check_in || null, check_in_end || null, check_out || null, confirmation || null, notes || null);
|
||||
|
||||
const accommodationId = result.lastInsertRowid;
|
||||
|
||||
@@ -239,6 +241,7 @@ export function createAccommodation(tripId: string | number, data: CreateAccommo
|
||||
const startDayDate = (db.prepare('SELECT date FROM days WHERE id = ?').get(start_day_id) as { date: string } | undefined)?.date || null;
|
||||
const meta: Record<string, string> = {};
|
||||
if (check_in) meta.check_in_time = check_in;
|
||||
if (check_in_end) meta.check_in_end_time = check_in_end;
|
||||
if (check_out) meta.check_out_time = check_out;
|
||||
db.prepare(`
|
||||
INSERT INTO reservations (trip_id, day_id, title, reservation_time, location, confirmation_number, notes, status, type, accommodation_id, metadata)
|
||||
@@ -258,25 +261,27 @@ export function getAccommodation(id: string | number, tripId: string | number) {
|
||||
|
||||
export function updateAccommodation(id: string | number, existing: DayAccommodation, fields: {
|
||||
place_id?: number; start_day_id?: number; end_day_id?: number;
|
||||
check_in?: string; check_out?: string; confirmation?: string; notes?: string;
|
||||
check_in?: string; check_in_end?: string; check_out?: string; confirmation?: string; notes?: string;
|
||||
}) {
|
||||
const newPlaceId = fields.place_id !== undefined ? fields.place_id : existing.place_id;
|
||||
const newStartDayId = fields.start_day_id !== undefined ? fields.start_day_id : existing.start_day_id;
|
||||
const newEndDayId = fields.end_day_id !== undefined ? fields.end_day_id : existing.end_day_id;
|
||||
const newCheckIn = fields.check_in !== undefined ? fields.check_in : existing.check_in;
|
||||
const newCheckInEnd = fields.check_in_end !== undefined ? fields.check_in_end : existing.check_in_end;
|
||||
const newCheckOut = fields.check_out !== undefined ? fields.check_out : existing.check_out;
|
||||
const newConfirmation = fields.confirmation !== undefined ? fields.confirmation : existing.confirmation;
|
||||
const newNotes = fields.notes !== undefined ? fields.notes : existing.notes;
|
||||
|
||||
db.prepare(
|
||||
'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?'
|
||||
).run(newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckOut, newConfirmation, newNotes, id);
|
||||
'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_in_end = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?'
|
||||
).run(newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckInEnd, newCheckOut, newConfirmation, newNotes, id);
|
||||
|
||||
// Sync check-in/out/confirmation to linked reservation
|
||||
const linkedRes = db.prepare('SELECT id, metadata FROM reservations WHERE accommodation_id = ?').get(Number(id)) as { id: number; metadata: string | null } | undefined;
|
||||
if (linkedRes) {
|
||||
const meta = linkedRes.metadata ? JSON.parse(linkedRes.metadata) : {};
|
||||
if (newCheckIn) meta.check_in_time = newCheckIn;
|
||||
if (newCheckInEnd) meta.check_in_end_time = newCheckInEnd;
|
||||
if (newCheckOut) meta.check_out_time = newCheckOut;
|
||||
db.prepare('UPDATE reservations SET metadata = ?, confirmation_number = COALESCE(?, confirmation_number) WHERE id = ?')
|
||||
.run(JSON.stringify(meta), newConfirmation || null, linkedRes.id);
|
||||
|
||||
@@ -63,7 +63,7 @@ export function validateShareTokenForPhoto(token: string, photoId: number): { jo
|
||||
FROM journey_photos jp
|
||||
JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
||||
JOIN journey_entries je ON jp.entry_id = je.id
|
||||
WHERE jp.id = ? AND je.journey_id = ?
|
||||
WHERE jp.photo_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;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Readable } from 'node:stream';
|
||||
import { Response } from 'express';
|
||||
import { canAccessTrip, db } from "../../db/database";
|
||||
import { safeFetch, SsrfBlockedError } from '../../utils/ssrfGuard';
|
||||
import { decrypt_api_key } from '../apiKeyCrypto';
|
||||
|
||||
// helpers for handling return types
|
||||
|
||||
@@ -42,6 +43,7 @@ export function handleServiceResult<T>(res: Response, result: ServiceResult<T>):
|
||||
export type Selection = {
|
||||
provider: string;
|
||||
asset_ids: string[];
|
||||
passphrase?: string;
|
||||
};
|
||||
|
||||
export type StatusResult = {
|
||||
@@ -59,7 +61,7 @@ export type SyncAlbumResult = {
|
||||
|
||||
|
||||
export type AlbumsList = {
|
||||
albums: Array<{ id: string; albumName: string; assetCount: number }>
|
||||
albums: Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>
|
||||
};
|
||||
|
||||
export type Asset = {
|
||||
@@ -230,17 +232,40 @@ export function getAlbumIdFromLink(tripId: string, linkId: string, userId: numbe
|
||||
}
|
||||
}
|
||||
|
||||
export function getAlbumLinkForSync(tripId: string, linkId: string, userId: number): ServiceResult<{ albumId: string; passphrase?: string }> {
|
||||
const access = canAccessTrip(tripId, userId);
|
||||
if (!access) return fail('Trip not found or access denied', 404);
|
||||
|
||||
try {
|
||||
const row = db.prepare('SELECT album_id, passphrase FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
|
||||
.get(linkId, tripId, userId) as { album_id: string; passphrase: string | null } | null;
|
||||
|
||||
if (!row) return fail('Album link not found', 404);
|
||||
|
||||
const decrypted = row.passphrase ? decrypt_api_key(row.passphrase) ?? undefined : undefined;
|
||||
return success({ albumId: row.album_id, passphrase: decrypted || undefined });
|
||||
} catch {
|
||||
return fail('Failed to retrieve album link', 500);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateSyncTimeForAlbumLink(linkId: string): void {
|
||||
db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
|
||||
}
|
||||
|
||||
export async function pipeAsset(url: string, response: Response, headers?: Record<string, string>, signal?: AbortSignal): Promise<void> {
|
||||
export async function pipeAsset(url: string, response: Response, headers?: Record<string, string>, signal?: AbortSignal, defaultCacheControl?: string): Promise<void> {
|
||||
try {
|
||||
const resp = await safeFetch(url, { headers, signal: signal as any });
|
||||
|
||||
response.status(resp.status);
|
||||
if (resp.headers.get('content-type')) response.set('Content-Type', resp.headers.get('content-type') as string);
|
||||
if (resp.headers.get('cache-control')) response.set('Cache-Control', resp.headers.get('cache-control') as string);
|
||||
if (!resp.ok) {
|
||||
response.set('Cache-Control', 'no-store, max-age=0');
|
||||
} else if (resp.headers.get('cache-control')) {
|
||||
response.set('Cache-Control', resp.headers.get('cache-control') as string);
|
||||
} else if (defaultCacheControl) {
|
||||
response.set('Cache-Control', defaultCacheControl);
|
||||
}
|
||||
if (resp.headers.get('content-length')) response.set('Content-Length', resp.headers.get('content-length') as string);
|
||||
if (resp.headers.get('content-disposition')) response.set('Content-Disposition', resp.headers.get('content-disposition') as string);
|
||||
|
||||
|
||||
@@ -241,12 +241,12 @@ export async function streamImmichAsset(
|
||||
const creds = getImmichCredentials(effectiveUserId);
|
||||
if (!creds) return { error: 'Not found', status: 404 };
|
||||
|
||||
const path = kind === 'thumbnail' ? 'thumbnail' : 'original';
|
||||
const timeout = kind === 'thumbnail' ? 10000 : 30000;
|
||||
const url = `${creds.immich_url}/api/assets/${assetId}/${path}`;
|
||||
const url = kind === 'thumbnail'
|
||||
? `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=thumbnail`
|
||||
: `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=fullsize`;
|
||||
|
||||
response.set('Cache-Control', 'public, max-age=86400');
|
||||
await pipeAsset(url, response, { 'x-api-key': creds.immich_api_key }, AbortSignal.timeout(timeout));
|
||||
await pipeAsset(url, response, { 'x-api-key': creds.immich_api_key }, AbortSignal.timeout(timeout), 'public, max-age=86400');
|
||||
}
|
||||
|
||||
// ── Albums ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -7,6 +7,7 @@ import { streamImmichAsset, getAssetInfo as getImmichAssetInfo } from './immichS
|
||||
import { streamSynologyAsset, getSynologyAssetInfo } from './synologyService';
|
||||
import type { ServiceResult, AssetInfo } from './helpersService';
|
||||
import { fail, success } from './helpersService';
|
||||
import { encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
|
||||
|
||||
// ── Lookup / Register ────────────────────────────────────────────────────
|
||||
|
||||
@@ -14,15 +15,22 @@ export function getOrCreateTrekPhoto(
|
||||
provider: string,
|
||||
assetId: string,
|
||||
ownerId: number,
|
||||
passphrase?: string,
|
||||
): number {
|
||||
const existing = db.prepare(
|
||||
'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?'
|
||||
).get(provider, assetId, ownerId) as { id: number } | undefined;
|
||||
if (existing) return existing.id;
|
||||
if (existing) {
|
||||
if (passphrase) {
|
||||
db.prepare('UPDATE trek_photos SET passphrase = ? WHERE id = ? AND passphrase IS NULL')
|
||||
.run(encrypt_api_key(passphrase), existing.id);
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const res = db.prepare(
|
||||
'INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)'
|
||||
).run(provider, assetId, ownerId);
|
||||
'INSERT INTO trek_photos (provider, asset_id, owner_id, passphrase) VALUES (?, ?, ?, ?)'
|
||||
).run(provider, assetId, ownerId, passphrase ? encrypt_api_key(passphrase) : null);
|
||||
return Number(res.lastInsertRowid);
|
||||
}
|
||||
|
||||
@@ -61,15 +69,18 @@ export async function streamPhoto(
|
||||
return;
|
||||
}
|
||||
|
||||
if (photo.file_path) {
|
||||
const localPath = path.join(__dirname, '../../../uploads', photo.file_path);
|
||||
if (fs.existsSync(localPath)) {
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
res.sendFile(localPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (photo.provider) {
|
||||
case 'local': {
|
||||
const filePath = path.join(__dirname, '../../../uploads', photo.file_path!);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
res.status(404).json({ error: 'File not found' });
|
||||
return;
|
||||
}
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
res.sendFile(filePath);
|
||||
res.status(404).json({ error: 'File not found' });
|
||||
return;
|
||||
}
|
||||
case 'immich': {
|
||||
@@ -77,7 +88,8 @@ export async function streamPhoto(
|
||||
return;
|
||||
}
|
||||
case 'synologyphotos': {
|
||||
await streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind);
|
||||
const passphrase = photo.passphrase ? (decrypt_api_key(photo.passphrase) || undefined) : undefined;
|
||||
await streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind, undefined, passphrase);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
@@ -112,7 +124,8 @@ export async function getPhotoInfo(
|
||||
return success(result.data as AssetInfo);
|
||||
}
|
||||
case 'synologyphotos': {
|
||||
return getSynologyAssetInfo(userId, photo.asset_id!, photo.owner_id!);
|
||||
const passphrase = photo.passphrase ? (decrypt_api_key(photo.passphrase) || undefined) : undefined;
|
||||
return getSynologyAssetInfo(userId, photo.asset_id!, photo.owner_id!, passphrase);
|
||||
}
|
||||
default:
|
||||
return fail(`Unknown provider: ${photo.provider}`, 400);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { decrypt_api_key, encrypt_api_key, maybe_encrypt_api_key } from '../apiK
|
||||
import { safeFetch, SsrfBlockedError, checkSsrf } from '../../utils/ssrfGuard';
|
||||
import { addTripPhotos } from './unifiedService';
|
||||
import {
|
||||
getAlbumIdFromLink,
|
||||
getAlbumLinkForSync,
|
||||
updateSyncTimeForAlbumLink,
|
||||
Selection,
|
||||
ServiceResult,
|
||||
@@ -432,41 +432,66 @@ export async function testSynologyConnection(userId: number, synologyUrl: string
|
||||
return success({ connected: true, user: { name: synologyUsername } });
|
||||
}
|
||||
|
||||
async function _fetchAllSynologyAlbums(userId: number, baseParams: ApiCallParams): Promise<ServiceResult<any[]>> {
|
||||
const pageSize = 100;
|
||||
const all: any[] = [];
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const result = await _requestSynologyApi<{ list: any[] }>(userId, { ...baseParams, offset, limit: pageSize });
|
||||
if (!result.success) return result as ServiceResult<any[]>;
|
||||
const items = result.data.list || [];
|
||||
all.push(...items);
|
||||
if (items.length < pageSize) break;
|
||||
offset += pageSize;
|
||||
}
|
||||
return success(all);
|
||||
}
|
||||
|
||||
export async function listSynologyAlbums(userId: number): Promise<ServiceResult<AlbumsList>> {
|
||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
|
||||
api: 'SYNO.Foto.Browse.Album',
|
||||
method: 'list',
|
||||
version: 4,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
});
|
||||
if (!result.success) return result as ServiceResult<AlbumsList>;
|
||||
const [personal, shared, sharedWithMe] = await Promise.allSettled([
|
||||
_fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Browse.Album', method: 'list', version: 4 }),
|
||||
_fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Browse.Album', method: 'list', version: 4, category: 'shared' }),
|
||||
_fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Sharing.Misc', method: 'list_shared_with_me_album', version: 1, additional: ['thumbnail', 'sharing_info'] }),
|
||||
]);
|
||||
|
||||
const albums = (result.data.list || []).map((album: any) => ({
|
||||
id: String(album.id),
|
||||
albumName: album.name || '',
|
||||
assetCount: album.item_count || 0,
|
||||
}));
|
||||
const map = new Map<string, { id: string; albumName: string; assetCount: number; passphrase?: string }>();
|
||||
|
||||
const addAlbums = (result: PromiseSettledResult<ServiceResult<any[]>>, extractPassphrase: (a: any) => string | undefined) => {
|
||||
if (result.status === 'rejected') return;
|
||||
if (!result.value.success) {
|
||||
console.warn('[Synology] album list partial failure:', (result.value as any).error?.message);
|
||||
return;
|
||||
}
|
||||
for (const album of result.value.data ?? []) {
|
||||
const id = String(album.id);
|
||||
const passphrase = extractPassphrase(album);
|
||||
map.set(id, { id, albumName: album.name || '', assetCount: album.item_count || 0, passphrase });
|
||||
}
|
||||
};
|
||||
|
||||
addAlbums(personal, () => undefined);
|
||||
addAlbums(shared, (a) => a.passphrase || undefined);
|
||||
addAlbums(sharedWithMe, (a) => a.passphrase || a.sharing_info?.passphrase || undefined);
|
||||
|
||||
if (map.size === 0 && personal.status === 'fulfilled' && !personal.value.success) {
|
||||
return personal.value as ServiceResult<AlbumsList>;
|
||||
}
|
||||
|
||||
const albums = [...map.values()].sort((a, b) => a.albumName.localeCompare(b.albumName));
|
||||
return success({ albums });
|
||||
}
|
||||
|
||||
|
||||
export async function getSynologyAlbumPhotos(userId: number, albumId: string): Promise<ServiceResult<AssetsList>> {
|
||||
export async function getSynologyAlbumPhotos(userId: number, albumId: string, passphrase?: string): Promise<ServiceResult<AssetsList>> {
|
||||
const allItems: SynologyPhotoItem[] = [];
|
||||
const pageSize = 1000;
|
||||
const pageSize = 50;
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
|
||||
api: 'SYNO.Foto.Browse.Item',
|
||||
method: 'list',
|
||||
version: 1,
|
||||
album_id: Number(albumId),
|
||||
offset,
|
||||
limit: pageSize,
|
||||
additional: ['thumbnail'],
|
||||
});
|
||||
const params: ApiCallParams = passphrase
|
||||
? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] }
|
||||
: { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] };
|
||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, params);
|
||||
if (!result.success) return result as ServiceResult<AssetsList>;
|
||||
const items = result.data.list || [];
|
||||
allItems.push(...items);
|
||||
@@ -483,23 +508,21 @@ export async function getSynologyAlbumPhotos(userId: number, albumId: string): P
|
||||
}
|
||||
|
||||
export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string, sid: string): Promise<ServiceResult<SyncAlbumResult>> {
|
||||
const response = getAlbumIdFromLink(tripId, linkId, userId);
|
||||
const response = getAlbumLinkForSync(tripId, linkId, userId);
|
||||
if (!response.success) return response as ServiceResult<SyncAlbumResult>;
|
||||
|
||||
const { albumId, passphrase } = response.data;
|
||||
|
||||
const allItems: SynologyPhotoItem[] = [];
|
||||
const pageSize = 1000;
|
||||
const pageSize = 50;
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
|
||||
api: 'SYNO.Foto.Browse.Item',
|
||||
method: 'list',
|
||||
version: 1,
|
||||
album_id: Number(response.data),
|
||||
offset,
|
||||
limit: pageSize,
|
||||
additional: ['thumbnail'],
|
||||
});
|
||||
const itemParams: ApiCallParams = passphrase
|
||||
? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] }
|
||||
: { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] };
|
||||
|
||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, itemParams);
|
||||
|
||||
if (!result.success) return result as ServiceResult<SyncAlbumResult>;
|
||||
|
||||
@@ -512,9 +535,9 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link
|
||||
const selection: Selection = {
|
||||
provider: SYNOLOGY_PROVIDER,
|
||||
asset_ids: allItems.map(item => String(item.additional?.thumbnail?.cache_key || '')).filter(id => id),
|
||||
passphrase,
|
||||
};
|
||||
|
||||
|
||||
const result = await addTripPhotos(tripId, userId, true, [selection], sid, linkId);
|
||||
if (!result.success) return result as ServiceResult<SyncAlbumResult>;
|
||||
|
||||
@@ -558,16 +581,18 @@ export async function searchSynologyPhotos(userId: number, from?: string, to?: s
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number): Promise<ServiceResult<AssetInfo>> {
|
||||
export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId: number, passphrase?: string): Promise<ServiceResult<AssetInfo>> {
|
||||
const parsedId = _splitPackedSynologyId(photoId);
|
||||
if (!parsedId) return fail('Invalid photo ID format', 400);
|
||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId, {
|
||||
const infoParams: ApiCallParams = {
|
||||
api: 'SYNO.Foto.Browse.Item',
|
||||
method: 'get',
|
||||
version: 5,
|
||||
id: `[${Number(parsedId.id) + 1}]`, //for some reason synology wants id moved by one to get image info
|
||||
additional: ['resolution', 'exif', 'gps', 'address', 'orientation', 'description'],
|
||||
});
|
||||
};
|
||||
if (passphrase) infoParams.passphrase = passphrase;
|
||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId, infoParams);
|
||||
|
||||
if (!result.success) return result as ServiceResult<AssetInfo>;
|
||||
|
||||
@@ -585,6 +610,8 @@ export async function streamSynologyAsset(
|
||||
targetUserId: number,
|
||||
photoId: string,
|
||||
kind: 'thumbnail' | 'original',
|
||||
size?: string,
|
||||
passphrase?: string,
|
||||
): Promise<void> {
|
||||
const parsedId = _splitPackedSynologyId(photoId);
|
||||
if (!parsedId) {
|
||||
@@ -610,6 +637,7 @@ export async function streamSynologyAsset(
|
||||
|
||||
|
||||
//size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ?
|
||||
const resolvedSize = size || 'sm';
|
||||
const params = kind === 'thumbnail'
|
||||
? new URLSearchParams({
|
||||
api: 'SYNO.Foto.Thumbnail',
|
||||
@@ -618,7 +646,7 @@ export async function streamSynologyAsset(
|
||||
mode: 'download',
|
||||
id: parsedId.id,
|
||||
type: 'unit',
|
||||
size: 'sm',
|
||||
size: resolvedSize,
|
||||
cache_key: parsedId.cacheKey,
|
||||
_sid: sid.data,
|
||||
})
|
||||
@@ -630,8 +658,9 @@ export async function streamSynologyAsset(
|
||||
unit_id: `[${parsedId.id}]`,
|
||||
_sid: sid.data,
|
||||
});
|
||||
if (passphrase) params.append('passphrase', passphrase);
|
||||
|
||||
const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
|
||||
await pipeAsset(url, response)
|
||||
await pipeAsset(url, response, undefined, undefined, 'public, max-age=86400')
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Selection,
|
||||
} from './helpersService';
|
||||
import { getOrCreateTrekPhoto } from './photoResolverService';
|
||||
import { encrypt_api_key } from '../apiKeyCrypto';
|
||||
|
||||
|
||||
function _providers(): Array<{id: string; enabled: boolean}> {
|
||||
@@ -104,13 +105,13 @@ export function listTripAlbumLinks(tripId: string, userId: number): ServiceResul
|
||||
//-----------------------------------------------
|
||||
// managing photos in trip
|
||||
|
||||
function _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean, albumLinkId?: string): ServiceResult<boolean> {
|
||||
function _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean, albumLinkId?: string, passphrase?: string): ServiceResult<boolean> {
|
||||
const providerResult = _validProvider(provider);
|
||||
if (!providerResult.success) {
|
||||
return providerResult as ServiceResult<boolean>;
|
||||
}
|
||||
try {
|
||||
const photoId = getOrCreateTrekPhoto(provider, assetId, userId);
|
||||
const photoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
|
||||
const result = db.prepare(
|
||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(tripId, userId, photoId, shared ? 1 : 0, albumLinkId || null);
|
||||
@@ -147,7 +148,7 @@ export async function addTripPhotos(
|
||||
for (const raw of selection.asset_ids) {
|
||||
const assetId = String(raw || '').trim();
|
||||
if (!assetId) continue;
|
||||
const result = _addTripPhoto(tripId, userId, selection.provider, assetId, shared, albumLinkId);
|
||||
const result = _addTripPhoto(tripId, userId, selection.provider, assetId, shared, albumLinkId, selection.passphrase);
|
||||
if (!result.success) {
|
||||
return result as ServiceResult<{ added: number; shared: boolean }>;
|
||||
}
|
||||
@@ -222,7 +223,7 @@ export function removeTripPhoto(
|
||||
// ----------------------------------------------
|
||||
// managing album links in trip
|
||||
|
||||
export function createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown): ServiceResult<true> {
|
||||
export function createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown, passphrase?: string): ServiceResult<true> {
|
||||
const access = canAccessTrip(tripId, userId);
|
||||
if (!access) {
|
||||
return fail('Trip not found or access denied', 404);
|
||||
@@ -246,9 +247,10 @@ export function createTripAlbumLink(tripId: string, userId: number, providerRaw:
|
||||
}
|
||||
|
||||
try {
|
||||
const encryptedPassphrase = passphrase ? encrypt_api_key(passphrase) : null;
|
||||
const result = db.prepare(
|
||||
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(tripId, userId, provider, albumId, albumName);
|
||||
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name, passphrase) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(tripId, userId, provider, albumId, albumName, encryptedPassphrase);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return fail('Album already linked', 409);
|
||||
|
||||
@@ -287,8 +287,8 @@ export function findOrCreateUser(
|
||||
if (existing) username = `${username}_${Date.now() % 10000}`;
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
).run(username, email, hash, role, sub, config.issuer);
|
||||
'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, ?, ?, 0)',
|
||||
).run(username, email, hash, role, sub, config.issuer, process.env.APP_VERSION || '0.0.0');
|
||||
|
||||
if (validInvite) {
|
||||
const updated = db.prepare(
|
||||
@@ -308,5 +308,5 @@ export function findOrCreateUser(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function touchLastLogin(userId: number): void {
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(userId);
|
||||
}
|
||||
|
||||
@@ -123,9 +123,9 @@ export function createReservation(tripId: string | number, data: CreateReservati
|
||||
// Sync check-in/out to accommodation if linked
|
||||
if (accommodation_id && metadata) {
|
||||
const meta = typeof metadata === 'string' ? JSON.parse(metadata) : metadata;
|
||||
if (meta.check_in_time || meta.check_out_time) {
|
||||
db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_out = COALESCE(?, check_out) WHERE id = ?')
|
||||
.run(meta.check_in_time || null, meta.check_out_time || null, accommodation_id);
|
||||
if (meta.check_in_time || meta.check_in_end_time || meta.check_out_time) {
|
||||
db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_in_end = COALESCE(?, check_in_end), check_out = COALESCE(?, check_out) WHERE id = ?')
|
||||
.run(meta.check_in_time || null, meta.check_in_end_time || null, meta.check_out_time || null, accommodation_id);
|
||||
}
|
||||
if (confirmation_number) {
|
||||
db.prepare('UPDATE day_accommodations SET confirmation = COALESCE(?, confirmation) WHERE id = ?')
|
||||
@@ -257,9 +257,9 @@ export function updateReservation(id: string | number, tripId: string | number,
|
||||
const resolvedMeta = metadata !== undefined ? metadata : (current.metadata ? JSON.parse(current.metadata as string) : null);
|
||||
if (resolvedAccId && resolvedMeta) {
|
||||
const meta = typeof resolvedMeta === 'string' ? JSON.parse(resolvedMeta) : resolvedMeta;
|
||||
if (meta.check_in_time || meta.check_out_time) {
|
||||
db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_out = COALESCE(?, check_out) WHERE id = ?')
|
||||
.run(meta.check_in_time || null, meta.check_out_time || null, resolvedAccId);
|
||||
if (meta.check_in_time || meta.check_in_end_time || meta.check_out_time) {
|
||||
db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_in_end = COALESCE(?, check_in_end), check_out = COALESCE(?, check_out) WHERE id = ?')
|
||||
.run(meta.check_in_time || null, meta.check_in_end_time || null, meta.check_out_time || null, resolvedAccId);
|
||||
}
|
||||
const resolvedConf = confirmation_number !== undefined ? confirmation_number : current.confirmation_number;
|
||||
if (resolvedConf) {
|
||||
|
||||
@@ -3,21 +3,99 @@ import { maybe_encrypt_api_key } from './apiKeyCrypto';
|
||||
|
||||
const ENCRYPTED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token']);
|
||||
|
||||
export const DEFAULTABLE_USER_SETTING_KEYS = [
|
||||
'temperature_unit',
|
||||
'dark_mode',
|
||||
'time_format',
|
||||
'route_calculation',
|
||||
'blur_booking_codes',
|
||||
'map_tile_url',
|
||||
] as const;
|
||||
|
||||
type DefaultableKey = typeof DEFAULTABLE_USER_SETTING_KEYS[number];
|
||||
|
||||
const VALID_VALUES: Partial<Record<DefaultableKey, unknown[]>> = {
|
||||
temperature_unit: ['fahrenheit', 'celsius'],
|
||||
time_format: ['12h', '24h'],
|
||||
dark_mode: [true, false, 'light', 'dark', 'auto'],
|
||||
};
|
||||
|
||||
const BOOLEAN_KEYS = new Set<DefaultableKey>(['route_calculation', 'blur_booking_codes']);
|
||||
|
||||
function parseValue(raw: string): unknown {
|
||||
try { return JSON.parse(raw); } catch { return raw; }
|
||||
}
|
||||
|
||||
export function getAdminUserDefaults(): Record<string, unknown> {
|
||||
const rows = db.prepare(
|
||||
"SELECT key, value FROM app_settings WHERE key LIKE 'default_user_setting_%'"
|
||||
).all() as { key: string; value: string }[];
|
||||
const defaults: Record<string, unknown> = {};
|
||||
for (const row of rows) {
|
||||
const settingKey = row.key.slice('default_user_setting_'.length);
|
||||
defaults[settingKey] = parseValue(row.value);
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
export function setAdminUserDefaults(partial: Record<string, unknown>): void {
|
||||
const upsert = db.prepare(
|
||||
`INSERT INTO app_settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
||||
);
|
||||
const del = db.prepare("DELETE FROM app_settings WHERE key = ?");
|
||||
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
for (const [key, value] of Object.entries(partial)) {
|
||||
if (!(DEFAULTABLE_USER_SETTING_KEYS as readonly string[]).includes(key)) {
|
||||
throw new Error(`Invalid setting key: ${key}`);
|
||||
}
|
||||
const typedKey = key as DefaultableKey;
|
||||
const appKey = `default_user_setting_${key}`;
|
||||
|
||||
// null/undefined means "reset to built-in default" — delete the row
|
||||
if (value === null || value === undefined) {
|
||||
del.run(appKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (BOOLEAN_KEYS.has(typedKey) && typeof value !== 'boolean') {
|
||||
throw new Error(`Setting ${key} must be a boolean`);
|
||||
}
|
||||
const allowed = VALID_VALUES[typedKey];
|
||||
if (allowed && !allowed.includes(value)) {
|
||||
throw new Error(`Invalid value for ${key}: ${value}`);
|
||||
}
|
||||
|
||||
upsert.run(appKey, JSON.stringify(value));
|
||||
}
|
||||
db.exec('COMMIT');
|
||||
} catch (err) {
|
||||
db.exec('ROLLBACK');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserSettings(userId: number): Record<string, unknown> {
|
||||
const adminDefaults = getAdminUserDefaults();
|
||||
|
||||
const rows = db.prepare('SELECT key, value FROM settings WHERE user_id = ?').all(userId) as { key: string; value: string }[];
|
||||
const settings: Record<string, unknown> = {};
|
||||
const userSettings: Record<string, unknown> = {};
|
||||
for (const row of rows) {
|
||||
if (ENCRYPTED_SETTING_KEYS.has(row.key)) {
|
||||
settings[row.key] = row.value ? '••••••••' : '';
|
||||
userSettings[row.key] = row.value ? '••••••••' : '';
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
settings[row.key] = JSON.parse(row.value);
|
||||
userSettings[row.key] = JSON.parse(row.value);
|
||||
} catch {
|
||||
settings[row.key] = row.value;
|
||||
userSettings[row.key] = row.value;
|
||||
}
|
||||
}
|
||||
return settings;
|
||||
|
||||
// Admin defaults fill in only for keys the user hasn't explicitly set
|
||||
return { ...adminDefaults, ...userSettings };
|
||||
}
|
||||
|
||||
function serializeValue(key: string, value: unknown): string {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import semver from 'semver';
|
||||
import { isAddonEnabled } from '../services/adminService.js';
|
||||
import type { NoticeCondition, SystemNotice } from './types.js';
|
||||
|
||||
interface ConditionContext {
|
||||
user: { login_count: number; first_seen_version: string; role: string; noTrips: number };
|
||||
currentAppVersion: string;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
// Custom predicate registry — extensible without modifying this file
|
||||
const customPredicates = new Map<string, (ctx: ConditionContext) => boolean>();
|
||||
export function registerPredicate(id: string, fn: (ctx: ConditionContext) => boolean): void {
|
||||
customPredicates.set(id, fn);
|
||||
}
|
||||
|
||||
function evaluateOne(condition: NoticeCondition, ctx: ConditionContext): boolean {
|
||||
switch (condition.kind) {
|
||||
case 'always':
|
||||
return true;
|
||||
case 'firstLogin':
|
||||
// login_count is incremented during login, so on the FIRST post-login fetch it's 1.
|
||||
return ctx.user.login_count <= 1;
|
||||
case 'noTrips':
|
||||
return ctx.user.noTrips === 0;
|
||||
|
||||
case 'existingUserBeforeVersion': {
|
||||
// Show to users who existed BEFORE this version was released.
|
||||
// Backfilled users have first_seen_version='0.0.0', so all pass semver.lt.
|
||||
const userVersion = semver.valid(ctx.user.first_seen_version) ?? '0.0.0';
|
||||
const noticeVersion = semver.valid(condition.version);
|
||||
if (!noticeVersion) return false;
|
||||
// Strip prerelease/build metadata so '3.0.0-pre.42' is treated as '3.0.0'.
|
||||
const appVersion = semver.coerce(ctx.currentAppVersion)?.version ?? '0.0.0';
|
||||
return (
|
||||
semver.lt(userVersion, noticeVersion) &&
|
||||
semver.gte(appVersion, noticeVersion)
|
||||
);
|
||||
}
|
||||
|
||||
case 'dateWindow': {
|
||||
const start = new Date(condition.startsAt);
|
||||
const end = condition.endsAt ? new Date(condition.endsAt) : null;
|
||||
return ctx.now >= start && (end === null || ctx.now <= end);
|
||||
}
|
||||
|
||||
case 'role':
|
||||
return condition.roles.includes(ctx.user.role as 'admin' | 'user');
|
||||
|
||||
case 'addonEnabled':
|
||||
return isAddonEnabled(condition.addonId);
|
||||
|
||||
case 'custom': {
|
||||
const fn = customPredicates.get(condition.id);
|
||||
if (!fn) {
|
||||
console.warn(`[systemNotices] unknown custom predicate: "${condition.id}"`);
|
||||
return false;
|
||||
}
|
||||
return fn(ctx);
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true only if ALL conditions pass (AND logic). */
|
||||
export function evaluate(notice: SystemNotice, ctx: ConditionContext): boolean {
|
||||
return notice.conditions.every(c => evaluateOne(c, ctx));
|
||||
}
|
||||
|
||||
export type { ConditionContext };
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { SystemNotice } from './types.js';
|
||||
|
||||
/**
|
||||
* SYSTEM NOTICE REGISTRY
|
||||
*
|
||||
* Rules for authoring:
|
||||
* - NEVER remove or renumber entries — dismissal tracking is keyed by `id`.
|
||||
* - `id` must be globally unique and stable across deployments.
|
||||
* - Title: ≤40 chars, sentence case, no trailing punctuation.
|
||||
* - Body: markdown (modal) or plain text (banner/toast). ≤400/140/80 chars.
|
||||
* - CTA label: ≤20 chars, a verb.
|
||||
* - Never hardcode version numbers/dates in translated strings — use bodyParams.
|
||||
* - See plans/system-notices/00-overview.md for full authoring guidelines.
|
||||
*/
|
||||
export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
// ── 3.0.0 upgrade notices (shown as a multipage modal to pre-3.0 users) ─────
|
||||
|
||||
{
|
||||
// Page 1 — breaking change first (warn → sorts before the two info notices)
|
||||
id: 'v3-photos',
|
||||
display: 'modal',
|
||||
severity: 'warn',
|
||||
icon: 'ImageOff',
|
||||
titleKey: 'system_notice.v3_photos.title',
|
||||
bodyKey: 'system_notice.v3_photos.body',
|
||||
dismissible: true,
|
||||
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 90,
|
||||
},
|
||||
|
||||
{
|
||||
// Page 2 — flagship feature (only when Journey addon is enabled)
|
||||
id: 'v3-journey',
|
||||
display: 'modal',
|
||||
severity: 'info',
|
||||
icon: 'BookOpen',
|
||||
titleKey: 'system_notice.v3_journey.title',
|
||||
bodyKey: 'system_notice.v3_journey.body',
|
||||
highlights: [
|
||||
{ labelKey: 'system_notice.v3_journey.highlight_timeline', iconName: 'CalendarDays' },
|
||||
{ labelKey: 'system_notice.v3_journey.highlight_photos', iconName: 'Images' },
|
||||
{ labelKey: 'system_notice.v3_journey.highlight_share', iconName: 'Globe' },
|
||||
{ labelKey: 'system_notice.v3_journey.highlight_export', iconName: 'FileText' },
|
||||
],
|
||||
cta: {
|
||||
kind: 'nav',
|
||||
labelKey: 'system_notice.v3_journey.cta_label',
|
||||
href: '/journey',
|
||||
},
|
||||
dismissible: true,
|
||||
conditions: [
|
||||
{ kind: 'existingUserBeforeVersion', version: '3.0.0' },
|
||||
{ kind: 'addonEnabled', addonId: 'journey' },
|
||||
],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 80,
|
||||
},
|
||||
|
||||
{
|
||||
// Page 3 — MCP OAuth 2.1 upgrade (only when MCP addon is enabled)
|
||||
id: 'v3-mcp',
|
||||
display: 'modal',
|
||||
severity: 'warn',
|
||||
icon: 'Bot',
|
||||
titleKey: 'system_notice.v3_mcp.title',
|
||||
bodyKey: 'system_notice.v3_mcp.body',
|
||||
highlights: [
|
||||
{ labelKey: 'system_notice.v3_mcp.highlight_oauth', iconName: 'KeyRound' },
|
||||
{ labelKey: 'system_notice.v3_mcp.highlight_scopes', iconName: 'ShieldCheck' },
|
||||
{ labelKey: 'system_notice.v3_mcp.highlight_deprecated', iconName: 'AlertTriangle' },
|
||||
{ labelKey: 'system_notice.v3_mcp.highlight_tools', iconName: 'Wrench' },
|
||||
],
|
||||
dismissible: true,
|
||||
conditions: [
|
||||
{ kind: 'existingUserBeforeVersion', version: '3.0.0' },
|
||||
{ kind: 'addonEnabled', addonId: 'mcp' },
|
||||
],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 75,
|
||||
},
|
||||
|
||||
{
|
||||
// Page 4 — other highlights
|
||||
id: 'v3-features',
|
||||
display: 'modal',
|
||||
severity: 'info',
|
||||
icon: 'Sparkles',
|
||||
titleKey: 'system_notice.v3_features.title',
|
||||
bodyKey: 'system_notice.v3_features.body',
|
||||
highlights: [
|
||||
{ labelKey: 'system_notice.v3_features.highlight_dashboard', iconName: 'LayoutDashboard' },
|
||||
{ labelKey: 'system_notice.v3_features.highlight_offline', iconName: 'WifiOff' },
|
||||
{ labelKey: 'system_notice.v3_features.highlight_search', iconName: 'Search' },
|
||||
{ labelKey: 'system_notice.v3_features.highlight_import', iconName: 'FileInput' },
|
||||
],
|
||||
dismissible: true,
|
||||
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 70,
|
||||
},
|
||||
|
||||
{
|
||||
// Page 1 — personal thank-you from the creator (shown first)
|
||||
id: 'v3-thankyou',
|
||||
display: 'modal',
|
||||
severity: 'info',
|
||||
icon: 'Heart',
|
||||
titleKey: 'system_notice.v3_thankyou.title',
|
||||
bodyKey: 'system_notice.v3_thankyou.body',
|
||||
dismissible: true,
|
||||
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 95,
|
||||
},
|
||||
|
||||
// ── Onboarding ─────────────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
id: 'welcome-v1',
|
||||
display: 'modal',
|
||||
severity: 'info',
|
||||
icon: 'Sparkles',
|
||||
titleKey: 'system_notice.welcome_v1.title',
|
||||
bodyKey: 'system_notice.welcome_v1.body',
|
||||
highlights: [
|
||||
{ labelKey: 'system_notice.welcome_v1.highlight_plan', iconName: 'Map' },
|
||||
{ labelKey: 'system_notice.welcome_v1.highlight_share', iconName: 'Users' },
|
||||
{ labelKey: 'system_notice.welcome_v1.highlight_offline', iconName: 'WifiOff' },
|
||||
],
|
||||
cta: {
|
||||
kind: 'action',
|
||||
labelKey: 'system_notice.welcome_v1.cta_label',
|
||||
actionId: 'open:trip-create',
|
||||
},
|
||||
dismissible: true,
|
||||
conditions: [{ kind: 'firstLogin' }],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 100,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createRequire } from 'module';
|
||||
import semver from 'semver';
|
||||
import { db } from '../db/database.js';
|
||||
import { SYSTEM_NOTICES } from './registry.js';
|
||||
import { evaluate } from './conditions.js';
|
||||
import type { SystemNoticeDTO } from './types.js';
|
||||
|
||||
function getCurrentAppVersion(): string {
|
||||
const fromEnv = semver.valid(process.env.APP_VERSION ?? '');
|
||||
if (fromEnv) return fromEnv;
|
||||
try {
|
||||
const pkg = require('../../package.json') as { version?: string };
|
||||
return semver.valid(pkg.version ?? '') ?? '0.0.0';
|
||||
} catch {
|
||||
return '0.0.0';
|
||||
}
|
||||
}
|
||||
|
||||
function severityWeight(s: string): number {
|
||||
return s === 'critical' ? 2 : s === 'warn' ? 1 : 0;
|
||||
}
|
||||
|
||||
export function getActiveNoticesFor(userId: number): SystemNoticeDTO[] {
|
||||
const user = db.prepare(
|
||||
'SELECT login_count, first_seen_version, role FROM users WHERE id = ?'
|
||||
).get(userId) as { login_count: number; first_seen_version: string; role: string } | undefined;
|
||||
|
||||
if (!user) return [];
|
||||
|
||||
const { count: tripCount } = db.prepare(
|
||||
'SELECT COUNT(*) AS count FROM trips WHERE user_id = ?'
|
||||
).get(userId) as { count: number };
|
||||
|
||||
const dismissedIds = new Set<string>(
|
||||
(db.prepare('SELECT notice_id FROM user_notice_dismissals WHERE user_id = ?')
|
||||
.all(userId) as Array<{ notice_id: string }>)
|
||||
.map(r => r.notice_id)
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const currentAppVersion = getCurrentAppVersion();
|
||||
const ctx = { user: { ...user, noTrips: tripCount }, currentAppVersion, now };
|
||||
|
||||
return SYSTEM_NOTICES
|
||||
.filter(n => {
|
||||
if (dismissedIds.has(n.id)) return false;
|
||||
if (n.expiresAt && now > new Date(n.expiresAt)) return false;
|
||||
return evaluate(n, ctx);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const pw = (b.priority ?? 0) - (a.priority ?? 0);
|
||||
if (pw !== 0) return pw;
|
||||
const sw = severityWeight(b.severity) - severityWeight(a.severity);
|
||||
if (sw !== 0) return sw;
|
||||
return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime();
|
||||
})
|
||||
.map(({ conditions: _c, publishedAt: _p, expiresAt: _e, priority: _pr, ...dto }) => dto);
|
||||
}
|
||||
|
||||
export function dismissNotice(userId: number, noticeId: string): boolean {
|
||||
const exists = SYSTEM_NOTICES.some(n => n.id === noticeId);
|
||||
if (!exists) return false;
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO user_notice_dismissals (user_id, notice_id, dismissed_at)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(userId, noticeId, Date.now());
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export type Display = 'modal' | 'banner' | 'toast';
|
||||
export type Severity = 'info' | 'warn' | 'critical';
|
||||
|
||||
export type NoticeCondition =
|
||||
| { kind: 'firstLogin' }
|
||||
| { kind: 'always' }
|
||||
| { kind: 'noTrips' }
|
||||
| { kind: 'existingUserBeforeVersion'; version: string }
|
||||
| { kind: 'dateWindow'; startsAt: string; endsAt?: string }
|
||||
| { kind: 'role'; roles: Array<'admin' | 'user'> }
|
||||
| { kind: 'addonEnabled'; addonId: string }
|
||||
| { kind: 'custom'; id: string };
|
||||
|
||||
export interface NoticeMedia {
|
||||
src: string;
|
||||
srcDark?: string;
|
||||
altKey: string;
|
||||
placement?: 'hero' | 'inline';
|
||||
aspectRatio?: string;
|
||||
}
|
||||
|
||||
export type NoticeCta =
|
||||
| { kind: 'nav'; labelKey: string; href: string }
|
||||
| { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean };
|
||||
|
||||
export interface SystemNotice {
|
||||
id: string;
|
||||
display: Display;
|
||||
severity: Severity;
|
||||
titleKey: string;
|
||||
bodyKey: string;
|
||||
bodyParams?: Record<string, string>;
|
||||
icon?: string;
|
||||
media?: NoticeMedia;
|
||||
highlights?: Array<{ labelKey: string; iconName?: string }>;
|
||||
cta?: NoticeCta;
|
||||
dismissible: boolean;
|
||||
conditions: NoticeCondition[];
|
||||
publishedAt: string;
|
||||
expiresAt?: string;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
// DTO sent to client (same shape minus the conditions — server evaluates those)
|
||||
export type SystemNoticeDTO = Omit<SystemNotice, 'conditions' | 'publishedAt' | 'expiresAt' | 'priority'>;
|
||||
@@ -17,6 +17,8 @@ export interface User {
|
||||
mfa_secret?: string | null;
|
||||
mfa_backup_codes?: string | null;
|
||||
must_change_password?: number | boolean;
|
||||
first_seen_version?: string;
|
||||
login_count?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
@@ -348,6 +350,7 @@ export interface TrekPhoto {
|
||||
thumbnail_path?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
passphrase?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,8 @@ const RESET_TABLES = [
|
||||
'notification_channel_preferences',
|
||||
'notifications',
|
||||
'audit_log',
|
||||
// System notices
|
||||
'user_notice_dismissals',
|
||||
// User data
|
||||
'settings',
|
||||
'mcp_tokens',
|
||||
|
||||
@@ -273,18 +273,19 @@ describe('Immich browse and search', () => {
|
||||
expect(res.body.buckets.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('IMMICH-042 — POST /search returns mapped assets', async () => {
|
||||
it('IMMICH-042 — POST /search returns mapped assets with hasMore flag', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/search`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
.send({ page: 1, size: 50 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.assets)).toBe(true);
|
||||
expect(res.body.assets[0]).toMatchObject({ id: 'asset-search-1', city: 'Paris', country: 'France' });
|
||||
expect(typeof res.body.hasMore).toBe('boolean');
|
||||
});
|
||||
|
||||
it('IMMICH-043 — POST /search when upstream throws returns 502', async () => {
|
||||
@@ -407,7 +408,7 @@ describe('Immich asset proxy', () => {
|
||||
.set('Cookie', authCookie(member.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('image/jpeg');
|
||||
expect(res.headers['content-type']).toContain('image/');
|
||||
});
|
||||
|
||||
it('IMMICH-057 — GET /assets/info where trip does not exist returns 403', async () => {
|
||||
@@ -611,43 +612,77 @@ describe('Immich syncAlbumAssets', () => {
|
||||
|
||||
// ── searchPhotos pagination safety ────────────────────────────────────────────
|
||||
|
||||
describe('Immich searchPhotos pagination safety', () => {
|
||||
it('IMMICH-090 — searchPhotos stops at page 20 when hasMore is always true', async () => {
|
||||
describe('Immich searchPhotos pagination pass-through', () => {
|
||||
it('IMMICH-090 — POST /search proxies client page param and returns hasMore', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
|
||||
// Return a full page of 1000 items on every call, so the loop would
|
||||
// run indefinitely without the page > 20 safety check.
|
||||
// Return a full page so hasMore=true (items.length >= size)
|
||||
const fullPageResponse = {
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => null },
|
||||
json: () => Promise.resolve({
|
||||
assets: {
|
||||
items: Array.from({ length: 1000 }, (_, i) => ({
|
||||
id: `asset-${i}`,
|
||||
items: Array.from({ length: 50 }, (_, i) => ({
|
||||
id: `asset-p2-${i}`,
|
||||
fileCreatedAt: '2024-06-01T10:00:00.000Z',
|
||||
exifInfo: { city: 'Paris', country: 'France' },
|
||||
exifInfo: { city: 'Berlin', country: 'Germany' },
|
||||
})),
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
} as any;
|
||||
|
||||
// Clear previous call history so the count only reflects this test
|
||||
vi.mocked(safeFetch).mockClear();
|
||||
vi.mocked(safeFetch).mockResolvedValue(fullPageResponse);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/search`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
.send({ page: 2, size: 50 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.assets)).toBe(true);
|
||||
// 20 pages × 1000 items = 20000 assets total (safety limit)
|
||||
expect(res.body.assets.length).toBe(20000);
|
||||
// safeFetch should have been called exactly 20 times (the safety limit)
|
||||
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(20);
|
||||
// Single page returned — not 20× aggregation
|
||||
expect(res.body.assets.length).toBe(50);
|
||||
expect(res.body.hasMore).toBe(true);
|
||||
// Immich was called exactly once
|
||||
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(1);
|
||||
// page=2 was forwarded to Immich
|
||||
const callBody = JSON.parse(vi.mocked(safeFetch).mock.calls[0][1]!.body as string);
|
||||
expect(callBody.page).toBe(2);
|
||||
});
|
||||
|
||||
it('IMMICH-091 — POST /search returns hasMore=false on last page', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
|
||||
// Partial page → hasMore=false
|
||||
const partialPageResponse = {
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => null },
|
||||
json: () => Promise.resolve({
|
||||
assets: {
|
||||
items: Array.from({ length: 3 }, (_, i) => ({
|
||||
id: `asset-last-${i}`,
|
||||
fileCreatedAt: '2024-06-01T10:00:00.000Z',
|
||||
exifInfo: { city: 'Rome', country: 'Italy' },
|
||||
})),
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
} as any;
|
||||
|
||||
vi.mocked(safeFetch).mockResolvedValue(partialPageResponse);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/search`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ page: 5, size: 50 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.assets.length).toBe(3);
|
||||
expect(res.body.hasMore).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -396,6 +396,139 @@ describe('Synology search and albums', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Album listing — multi-source merge ───────────────────────────────────────
|
||||
|
||||
describe('Synology listSynologyAlbums multi-source merge', () => {
|
||||
// Capture and restore the default safeFetch implementation around each test
|
||||
// in this block so the persistent mockImplementation we set doesn't leak.
|
||||
let _savedImpl: ((...args: any[]) => any) | undefined;
|
||||
beforeEach(() => { _savedImpl = vi.mocked(safeFetch).getMockImplementation(); });
|
||||
afterEach(() => { if (_savedImpl) vi.mocked(safeFetch).mockImplementation(_savedImpl); });
|
||||
|
||||
it('SYNO-027 — personal-only: shared and shared-with-me return failure → merged result contains personal albums, no error', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
vi.mocked(safeFetch).mockImplementation((_url: string, init?: any) => {
|
||||
// Always read both URL params and body params; body takes precedence for request-specific fields.
|
||||
const urlParams = (() => { try { return new URL(String(_url)).searchParams; } catch { return new URLSearchParams(); } })();
|
||||
const bodyParams: URLSearchParams = init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? ''));
|
||||
const api = urlParams.get('api') || bodyParams.get('api') || '';
|
||||
const category = bodyParams.get('category') || urlParams.get('category');
|
||||
|
||||
if (api === 'SYNO.API.Auth') {
|
||||
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { sid: 'sid-027' } }), body: null } as any);
|
||||
}
|
||||
if (api === 'SYNO.Foto.Browse.Album') {
|
||||
if (!category) {
|
||||
// personal albums
|
||||
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 1, name: 'Personal Album', item_count: 5 }] } }), body: null } as any);
|
||||
}
|
||||
// shared category → failure
|
||||
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: false, error: { code: 400 } }), body: null } as any);
|
||||
}
|
||||
if (api === 'SYNO.Foto.Sharing.Misc') {
|
||||
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: false, error: { code: 400 } }), body: null } as any);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected API: ${api}`));
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/albums`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.albums)).toBe(true);
|
||||
expect(res.body.albums).toHaveLength(1);
|
||||
expect(res.body.albums[0]).toMatchObject({ albumName: 'Personal Album', assetCount: 5 });
|
||||
});
|
||||
|
||||
it('SYNO-028 — full merge: personal + shared (with passphrase) + shared-with-me (with sharing_info.passphrase) → 4 albums with correct passphrases', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
vi.mocked(safeFetch).mockImplementation((_url: string, init?: any) => {
|
||||
const urlParams = (() => { try { return new URL(String(_url)).searchParams; } catch { return new URLSearchParams(); } })();
|
||||
const bodyParams: URLSearchParams = init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? ''));
|
||||
const api = urlParams.get('api') || bodyParams.get('api') || '';
|
||||
const category = bodyParams.get('category') || urlParams.get('category');
|
||||
|
||||
if (api === 'SYNO.API.Auth') {
|
||||
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { sid: 'sid-028' } }), body: null } as any);
|
||||
}
|
||||
if (api === 'SYNO.Foto.Browse.Album') {
|
||||
if (!category) {
|
||||
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 10, name: 'Alpha Album', item_count: 3 }, { id: 11, name: 'Beta Album', item_count: 7 }] } }), body: null } as any);
|
||||
}
|
||||
// shared category — one album with passphrase
|
||||
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 20, name: 'Shared Out', item_count: 2, passphrase: 'pp-abc' }] } }), body: null } as any);
|
||||
}
|
||||
if (api === 'SYNO.Foto.Sharing.Misc') {
|
||||
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 30, name: 'Shared With Me', item_count: 4, sharing_info: { passphrase: 'pp-xyz' } }] } }), body: null } as any);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected API: ${api}`));
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/albums`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.albums)).toBe(true);
|
||||
expect(res.body.albums).toHaveLength(4);
|
||||
|
||||
const byName = (name: string) => res.body.albums.find((a: any) => a.albumName === name);
|
||||
expect(byName('Alpha Album')).toMatchObject({ id: '10', assetCount: 3 });
|
||||
expect(byName('Beta Album')).toMatchObject({ id: '11', assetCount: 7 });
|
||||
expect(byName('Shared Out')).toMatchObject({ id: '20', passphrase: 'pp-abc' });
|
||||
expect(byName('Shared With Me')).toMatchObject({ id: '30', passphrase: 'pp-xyz' });
|
||||
|
||||
// personal albums carry no passphrase
|
||||
expect(byName('Alpha Album').passphrase).toBeUndefined();
|
||||
});
|
||||
|
||||
it('SYNO-029 — dedup: same album id=99 in personal and shared-with-me → last-write-wins gives passphrase from shared-with-me', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
vi.mocked(safeFetch).mockImplementation((_url: string, init?: any) => {
|
||||
const urlParams = (() => { try { return new URL(String(_url)).searchParams; } catch { return new URLSearchParams(); } })();
|
||||
const bodyParams: URLSearchParams = init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? ''));
|
||||
const api = urlParams.get('api') || bodyParams.get('api') || '';
|
||||
const category = bodyParams.get('category') || urlParams.get('category');
|
||||
|
||||
if (api === 'SYNO.API.Auth') {
|
||||
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { sid: 'sid-029' } }), body: null } as any);
|
||||
}
|
||||
if (api === 'SYNO.Foto.Browse.Album') {
|
||||
if (!category) {
|
||||
// personal: album id=99 without passphrase
|
||||
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 99, name: 'Dup Album', item_count: 10 }] } }), body: null } as any);
|
||||
}
|
||||
// shared: no entries
|
||||
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [] } }), body: null } as any);
|
||||
}
|
||||
if (api === 'SYNO.Foto.Sharing.Misc') {
|
||||
// shared-with-me: same album id=99 with passphrase
|
||||
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 99, name: 'Dup Album', item_count: 10, passphrase: 'pp-dup' }] } }), body: null } as any);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected API: ${api}`));
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/albums`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.albums)).toBe(true);
|
||||
// Deduplicated to a single album
|
||||
expect(res.body.albums).toHaveLength(1);
|
||||
expect(res.body.albums[0]).toMatchObject({ id: '99', albumName: 'Dup Album' });
|
||||
// shared-with-me wins (last write) → passphrase present
|
||||
expect(res.body.albums[0].passphrase).toBe('pp-dup');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Asset access ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Synology asset access', () => {
|
||||
@@ -571,6 +704,7 @@ describe('Synology auth checks', () => {
|
||||
// ── Album sync ────────────────────────────────────────────────────────────────
|
||||
|
||||
import { addAlbumLink } from '../helpers/factories';
|
||||
import { encrypt_api_key } from '../../src/services/apiKeyCrypto';
|
||||
|
||||
describe('Synology syncSynologyAlbumLink', () => {
|
||||
it('SYNO-050 — POST sync happy path: trip owner with album link saves photos to DB', async () => {
|
||||
@@ -632,6 +766,70 @@ describe('Synology syncSynologyAlbumLink', () => {
|
||||
it('SYNO-053 — POST sync without auth returns 401', async () => {
|
||||
expect((await request(app).post(`${SYNO}/trips/1/album-links/1/sync`)).status).toBe(401);
|
||||
});
|
||||
|
||||
it('SYNO-054 — POST sync with passphrase link: uses passphrase in item-list call and persists encrypted passphrase on trek_photos', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run();
|
||||
|
||||
// Insert a link with an encrypted passphrase directly into the DB.
|
||||
const rawPassphrase = 'syno-share-pass-abc';
|
||||
const result = testDb.prepare(
|
||||
'INSERT INTO trip_album_links (trip_id, user_id, provider, album_id, album_name, passphrase) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(trip.id, user.id, 'synologyphotos', '99', 'Shared Album', encrypt_api_key(rawPassphrase));
|
||||
const link = testDb.prepare('SELECT * FROM trip_album_links WHERE id = ?').get(result.lastInsertRowid) as any;
|
||||
|
||||
// Override safeFetch so browse-item only succeeds when called with the passphrase param.
|
||||
vi.mocked(safeFetch).mockImplementation(async (url: any, init?: any) => {
|
||||
const bodyParams = init?.body instanceof URLSearchParams
|
||||
? init.body
|
||||
: new URLSearchParams(String(init?.body ?? ''));
|
||||
const apiName = bodyParams.get('api') || (new URL(String(url)).searchParams.get('api') ?? '');
|
||||
|
||||
if (apiName === 'SYNO.API.Auth') {
|
||||
return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { sid: 'fake-sid-054' } }), body: null } as any;
|
||||
}
|
||||
|
||||
if (apiName === 'SYNO.Foto.Browse.Item') {
|
||||
// Only respond successfully when the passphrase param is present.
|
||||
if (bodyParams.get('passphrase') !== rawPassphrase) {
|
||||
return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: false, error: { code: 105 } }), body: null } as any;
|
||||
}
|
||||
return {
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
list: [{ id: 201, filename: 'shared.jpg', filesize: 512000, time: 1717228800, additional: { thumbnail: { cache_key: '201_sharedkey' } } }],
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
} as any;
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`SYNO-054: unexpected safeFetch call: api=${apiName}`));
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${SYNO}/trips/${trip.id}/album-links/${link.id}/sync`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.added).toBeGreaterThan(0);
|
||||
|
||||
// The trek_photos row for the synced photo must have a non-null passphrase.
|
||||
const photo = testDb.prepare(`
|
||||
SELECT tkp.passphrase FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tp.trip_id = ? AND tp.user_id = ?
|
||||
LIMIT 1
|
||||
`).get(trip.id, user.id) as { passphrase: string | null } | undefined;
|
||||
|
||||
expect(photo).toBeDefined();
|
||||
expect(photo!.passphrase).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Session retry logic ───────────────────────────────────────────────────────
|
||||
@@ -691,8 +889,9 @@ describe('Synology session retry on error codes 106/107/119', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.albums)).toBe(true);
|
||||
expect(res.body.albums[0]).toMatchObject({ albumName: 'Retry Album' });
|
||||
// Four safeFetch calls: login, failed album list, re-login, successful album list
|
||||
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(4);
|
||||
// Five safeFetch calls: login, failed album list (119), re-login, successful album list retry,
|
||||
// plus one additional call for the shared or shared-with-me source (handled by default mock)
|
||||
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('SYNO-061 — request retries with fresh session when API returns error code 106', async () => {
|
||||
@@ -735,7 +934,9 @@ describe('Synology session retry on error codes 106/107/119', () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.albums[0]).toMatchObject({ albumName: 'Timeout Album' });
|
||||
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(4);
|
||||
// Five safeFetch calls: login, failed album list (106), re-login, successful album list retry,
|
||||
// plus one additional call for the shared or shared-with-me source (handled by default mock)
|
||||
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -843,6 +1044,83 @@ describe('Synology searchSynologyPhotos date range', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Search pagination ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Synology search pagination', () => {
|
||||
it('SYNO-025 — POST /search with { page: 2, size: 50 } sends offset=50 and limit=50 to Synology API', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
let capturedBody: URLSearchParams | null = null;
|
||||
vi.mocked(safeFetch)
|
||||
.mockResolvedValueOnce({
|
||||
// login
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockImplementationOnce((_url: string, init?: any) => {
|
||||
capturedBody = init?.body instanceof URLSearchParams
|
||||
? init.body
|
||||
: new URLSearchParams(String(init?.body ?? ''));
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { list: [] } }),
|
||||
body: null,
|
||||
} as any);
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${SYNO}/search`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ page: 2, size: 50 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedBody).not.toBeNull();
|
||||
// With the fix: limit=50 is resolved first, then offset = (2-1)*50 = 50
|
||||
expect(capturedBody!.get('offset')).toBe('50');
|
||||
expect(capturedBody!.get('limit')).toBe('50');
|
||||
});
|
||||
|
||||
it('SYNO-026 — POST /search with { page: 3, size: 25 } sends offset=50 and limit=25 to Synology API', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
let capturedBody: URLSearchParams | null = null;
|
||||
vi.mocked(safeFetch)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockImplementationOnce((_url: string, init?: any) => {
|
||||
capturedBody = init?.body instanceof URLSearchParams
|
||||
? init.body
|
||||
: new URLSearchParams(String(init?.body ?? ''));
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { list: [] } }),
|
||||
body: null,
|
||||
} as any);
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${SYNO}/search`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ page: 3, size: 25 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedBody).not.toBeNull();
|
||||
// page 3 → page index = 2 (after subtracting 1), offset = 2 * 25 = 50
|
||||
expect(capturedBody!.get('offset')).toBe('50');
|
||||
expect(capturedBody!.get('limit')).toBe('25');
|
||||
});
|
||||
});
|
||||
|
||||
// ── SSRF catch branch in _fetchSynologyJson ────────────────────────────────────
|
||||
|
||||
describe('Synology SSRF blocked error handling', () => {
|
||||
@@ -865,13 +1143,21 @@ describe('Synology SSRF blocked error handling', () => {
|
||||
expect(res.body.connected).toBe(false);
|
||||
});
|
||||
|
||||
it('SYNO-081 — safeFetch throwing SsrfBlockedError during album list returns 400', async () => {
|
||||
it('SYNO-081 — safeFetch throwing SsrfBlockedError during one album source is swallowed; other sources still return albums', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
const { SsrfBlockedError: SsrfErr } = await import('../../src/utils/ssrfGuard');
|
||||
|
||||
// Auth succeeds, but the album-list call throws SsrfBlockedError
|
||||
const emptyAlbumResponse = {
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { list: [{ id: 99, name: 'Shared Album', item_count: 2, passphrase: 'pp-test' }] } }),
|
||||
body: null,
|
||||
} as any;
|
||||
|
||||
// Auth succeeds, personal album source throws SSRF, shared + shared-with-me succeed.
|
||||
// listSynologyAlbums uses Promise.allSettled so the SSRF failure is logged and skipped.
|
||||
vi.mocked(safeFetch)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true, status: 200,
|
||||
@@ -879,14 +1165,17 @@ describe('Synology SSRF blocked error handling', () => {
|
||||
json: async () => ({ success: true, data: { sid: 'sid-x' } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockRejectedValueOnce(new SsrfErr('Private IP detected'));
|
||||
.mockRejectedValueOnce(new SsrfErr('Private IP detected'))
|
||||
.mockResolvedValueOnce(emptyAlbumResponse)
|
||||
.mockResolvedValueOnce(emptyAlbumResponse);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/albums`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
// _fetchSynologyJson catches SsrfBlockedError and returns fail(message, 400)
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
// Personal failed (SSRF), shared sources returned an album — 200 with non-empty list.
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.albums)).toBe(true);
|
||||
expect(res.body.albums.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user