Compare commits

...

26 Commits

Author SHA1 Message Date
Julien G. 0c4de72356 Merge pull request #683 from mauriceboe/feat/system-notices
Feat/system notices
2026-04-16 15:14:05 +02:00
jubnl 5e8602c50a fix(system-notices): fix FE-SN-BANNER-004 to reflect highest-priority-first array order 2026-04-16 15:08:52 +02:00
jubnl 61b8070626 fix(system-notices): coerce prerelease app version before semver comparison 2026-04-16 14:58:38 +02:00
jubnl 5caaeff67c fix: syntax 2026-04-16 14:55:35 +02:00
jubnl 92a1f9c448 fix(system-notices): reset notice store on logout so addon-gated notices show after re-login 2026-04-16 14:53:33 +02:00
jubnl 58a8e97f94 feat(system-notices): add v3-mcp notice for OAuth 2.1 upgrade
Adds a warn-severity modal notice targeting existing users who have the
MCP addon enabled. Communicates that OAuth 2.1 is now the recommended
auth method, static trek_ tokens are deprecated, and the toolset has
been significantly expanded. Priority 75 — slots between v3-journey and
v3-features in the upgrade modal sequence. Translations for all 15 languages.
2026-04-16 14:48:13 +02:00
Julien G. 815b725f87 Merge pull request #682 from mauriceboe/dev
Dev
2026-04-16 14:38:24 +02:00
Julien G. d80bbd5bed Merge branch 'feat/system-notices' into dev 2026-04-16 14:38:14 +02:00
jubnl 293506217e feat(notices): add system notice infrastructure
Server-side notice registry with per-user condition evaluation (firstLogin,
existingUserBeforeVersion, addonEnabled, dateWindow, role, custom).
Notices are sorted by priority then severity, filtered against dismissals
stored in a new user_notice_dismissals table, and served via
GET /api/system-notices/active + POST /api/system-notices/:id/dismiss.

Client renders notices through a host component that partitions by
display type (modal / banner / toast). The modal renderer supports
multi-page pagination with directional slide transitions, keyboard
navigation, and correct dismiss-all semantics on CTA / X / ESC.
Dismissals are optimistic with a single background retry.

Includes 3.0.0 upgrade notices (v3-photos, v3-journey, v3-features),
onboarding welcome modal, and full i18n coverage across 15 languages.
The /journey route is addon-gated on both client and server.

Also includes: unit + integration test suites, registry integrity test
that validates action CTA IDs against client source, and technical
documentation in docs/system-notices.md.
2026-04-16 14:36:33 +02:00
Maurice 9739542a3a Merge pull request #672 from mauriceboe/feature/uncategorized-filter
feat: add uncategorized filter to category dropdown and more
2026-04-16 00:34:28 +02:00
Maurice 9f3a88223d fix: update ReservationModal test for check-in time range fields
Use getAllByText for check-in labels since both "Check-in" and
"Check-in until" now match the /Check-in/i pattern.
2026-04-16 00:29:25 +02:00
Maurice 409a63633c feat: support check-in time ranges for hotel accommodations
- Add check_in_end column to day_accommodations (Migration 102)
- Server: create/update accommodation accepts check_in_end
- Bidirectional sync: check_in_end synced between accommodation
  and linked reservation metadata (check_in_end_time)
- DayDetailPanel: shows check-in range (e.g. "14:00 – 22:00"),
  new "Until" time picker in hotel form
- ReservationModal: new check-in-until field for hotel bookings
- ReservationsPanel: displays check-in range in metadata cells
- i18n: checkInUntil keys in all 15 languages

Closes #366
2026-04-16 00:23:00 +02:00
Maurice 125436fa87 fix: correct test matchers for list import and reservations
- PlacesSidebar: match "List Import" (actual i18n value) not "Import List"
- ReservationsPanel: use unique titles to avoid matching filter buttons
2026-04-16 00:12:06 +02:00
Maurice 975846c236 fix: update tests for naver always-on and reservations redesign
- Remove server test for naver addon disabled (addon check removed)
- Update PlacesSidebar tests: "Google List" → "Import List" (both
  providers always shown)
- Update ReservationsPanel tests: status is always a span (no toggle),
  remove click-to-toggle test, update summary test
2026-04-16 00:04:14 +02:00
Maurice 7befb7d555 feat: enable naver list import by default, remove addon toggle
- Remove addon check from naver import endpoint
- Naver import always available alongside Google list import
- Migration 101: auto-enable naver_list_import for existing installs
- Remove unused isAddonEnabled import from places route
- Remove unused useAddonStore import from PlacesSidebar
2026-04-15 23:57:09 +02:00
Maurice 099255761c feat: collab sub-feature toggles and provider icons
- Add admin toggles for individual collab sections (Chat, Notes,
  Polls, What's Next) stored in app_settings
- CollabPanel adapts layout dynamically: chat always fixed 380px,
  remaining panels share space equally
- Mobile: disabled tabs are hidden
- Add Immich and Synology Photos SVG icons to photo provider toggles
- Add Luggage icon to bag tracking sub-toggle
- API: GET/PUT /admin/collab-features endpoints
- i18n: all 15 languages updated

Closes #604
2026-04-15 23:53:16 +02:00
Maurice c8fc21b8bd fix: reservations panel mobile responsiveness
- Hide type filter pills on mobile (< md breakpoint)
- Move add button right-aligned on mobile
- Separate booking code into its own row below date/time
- Hide weekday in date on mobile for space
- Reduce padding on mobile
2026-04-15 23:26:49 +02:00
Maurice 9186b8c850 feat: redesign reservations panel with unified toolbar and responsive grid
- Unified toolbar with title, type filter pills (with count badges),
  and add button in one row
- Cards redesigned: labeled fields in rounded boxes, status/type in
  header, edit/delete actions right-aligned
- Responsive grid with max 3 columns, auto-filling full width
- Type filters persist in sessionStorage per trip
- Widen reservations tab container to match other tabs (1800px)
2026-04-15 23:21:51 +02:00
Maurice e38c5fed44 feat: add uncategorized filter option to category dropdown
Add a "No Category" option to the category filter dropdown in the
places sidebar, allowing users to filter for places without an
assigned category. The filter is synced with the map view.

Closes #607
2026-04-15 22:54:23 +02:00
Julien G. 3b069bc543 Merge pull request #671 from mauriceboe/feat/admin-default-user-settings
feat(admin): add admin-configurable default user settings
2026-04-15 22:47:08 +02:00
jubnl 618b1b8697 feat(admin): add map preview and auto-save to default user settings tab 2026-04-15 22:41:33 +02:00
jubnl e45a0efce3 feat(admin): add admin-configurable default user settings
Allow admins to set instance-wide defaults for temperature unit, color
mode, time format, route calculation, blur booking codes, and map tile
URL via a new Admin > User Defaults tab. Defaults are stored in
app_settings (prefixed default_user_setting_*) and applied at read time
as a fallback — user's own explicit values always take priority.
Translations added for all 16 supported languages.
2026-04-15 22:31:41 +02:00
Julien G. 597a5f7a1d Merge pull request #670 from mauriceboe/fix/immich-heic-rendering
fix(immich): serve fullsize thumbnail for original to fix HEIC rendering
2026-04-15 22:07:28 +02:00
jubnl 42c216b00b fix(immich): serve fullsize thumbnail for original to fix HEIC rendering
Raw /assets/{id}/original returns HEIC bytes which only Safari can
render natively. Switch to /assets/{id}/thumbnail?size=fullsize which
Immich transcodes to a browser-compatible format.

Closes #668
2026-04-15 22:02:48 +02:00
jubnl f3751ab9aa ci: manual trigger for prerelease 2026-04-15 21:35:53 +02:00
jubnl 9e8d101d63 fix(ntfy): improve admin ntfy UX and add clear token button
- Add missing admin.ntfy.hint translation key in all 15 languages
- Add admin ntfy server hint clarifying it is the default for users
- Expose admin_ntfy_server via PreferencesMatrix so user settings
  placeholder reflects the admin-configured default
- Add clear token button to admin ntfy panel (same pattern as user settings)
- Extract common.clear from settings.ntfyUrl.clearToken across all 15 languages
2026-04-15 20:23:31 +02:00
75 changed files with 5133 additions and 404 deletions
-5
View File
@@ -1,11 +1,6 @@
name: Build & Push Docker Image (Prerelease)
on:
push:
branches: [dev]
paths-ignore:
- 'docs/**'
- '**/*.md'
workflow_dispatch:
inputs:
bump:
+40 -24
View File
@@ -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"
},
+1
View File
@@ -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
View File
@@ -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>
}
+4
View File
@@ -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 = {
+69 -4
View File
@@ -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>
)
}
+123 -36
View File
@@ -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>
)
@@ -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 || []
@@ -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>
)}
</>
@@ -11,6 +11,7 @@ interface PreferencesMatrix {
available_channels: { email: boolean; webhook: boolean; inapp: boolean; ntfy: boolean }
event_types: string[]
implemented_combos: Record<string, string[]>
defaults?: { ntfyServer: string | null }
}
const CHANNEL_LABEL_KEYS: Record<string, string> = {
@@ -233,7 +234,7 @@ export default function NotificationsTab(): React.ReactElement {
type="text"
value={ntfyServer}
onChange={e => setNtfyServer(e.target.value)}
placeholder={t('settings.ntfyUrl.serverPlaceholder')}
placeholder={matrix.defaults?.ntfyServer || t('settings.ntfyUrl.serverPlaceholder')}
style={{ width: '100%', boxSizing: 'border-box', fontSize: 13, padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)', marginBottom: 6 }}
/>
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
@@ -253,7 +254,7 @@ export default function NotificationsTab(): React.ReactElement {
onClick={clearNtfyToken}
style={{ fontSize: 12, padding: '6px 12px', background: 'transparent', color: 'var(--color-danger, #e53e3e)', border: '1px solid var(--color-danger, #e53e3e)', borderRadius: 6, cursor: 'pointer' }}
>
{t('settings.ntfyUrl.clearToken')}
{t('common.clear')}
</button>
)}
<button
@@ -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,368 @@
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 () => {
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A', cta: { kind: 'nav', labelKey: 'Go to trips', href: '/trips' } });
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();
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();
// Dismiss notice A — store shrinks, parent re-renders with [B, C]
await act(async () => {
fireEvent.click(screen.getByLabelText('Dismiss'));
useSystemNoticeStore.setState({ notices: [noticeB, noticeC], loaded: true });
rerender(<ModalRenderer notices={[noticeB, noticeC]} />);
});
await flushGraceDelay();
// Must show B (idx=0), not C (idx=1 — the old buggy behavior)
expect(screen.getByText('Notice B')).toBeTruthy();
expect(screen.getByText('1 / 2')).toBeTruthy();
});
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();
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 current is dismissible', 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();
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,601 @@
import React, { useState, useEffect, useRef } from 'react';
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 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">
{/* Dismiss X button */}
{notice.dismissible && (
<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>
)}
<div className="p-8">
{/* Severity icon (when no hero) */}
{!notice.media && (
<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 */}
<h2
id={titleId}
className="text-xl font-semibold text-center text-slate-900 dark:text-slate-100 mb-3"
>
{title}
</h2>
{/* Body — markdown */}
<div
id={bodyId}
className="text-sm leading-relaxed text-center text-slate-600 dark:text-slate-400 max-w-[340px] mx-auto mb-4"
>
<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-blue-600 dark:text-blue-400 underline hover:no-underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
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 max-w-[340px] 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="max-w-[340px] 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 mb-4">
<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-2">
{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 && 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 touchStartY = useRef<number | 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');
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 (same as clicking X)
useEffect(() => {
if (!visible || !notice?.dismissible) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleDismissAll();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [visible, notice?.dismissible]); // 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]);
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();
}
}
// 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');
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 ? handleDismiss : undefined}
/>
{/* Bottom sheet */}
<div
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-all ${dur} ${ease} ${mobileMotion}`}
onTouchStart={e => { touchStartY.current = e.touches[0].clientY; }}
onTouchEnd={e => {
if (touchStartY.current !== null && notice.dismissible) {
const delta = e.changedTouches[0].clientY - touchStartY.current;
if (delta > 80) handleDismiss();
}
touchStartY.current = null;
}}
>
{/* 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>
<div ref={contentWrapperRef}>
<NoticeContent {...contentProps} />
</div>
</div>
</div>
);
}
// Desktop centered modal
const maxWidth = notice.severity === 'critical' ? 'max-w-[560px]' : 'max-w-[480px]';
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 ? handleDismiss : 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 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);
}
+60 -1
View File
@@ -8,6 +8,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'common.showMore': 'عرض المزيد',
'common.showLess': 'عرض أقل',
'common.cancel': 'إلغاء',
'common.clear': 'مسح',
'common.delete': 'حذف',
'common.edit': 'تعديل',
'common.add': 'إضافة',
@@ -463,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',
@@ -583,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': 'قالب جديد',
@@ -1006,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': 'ربط بالإقامة',
@@ -1490,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': 'تعديل الإقامة',
@@ -1824,7 +1841,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.ntfyUrl.test': 'اختبار',
'settings.ntfyUrl.testSuccess': 'تم إرسال إشعار Ntfy التجريبي بنجاح',
'settings.ntfyUrl.testFailed': 'فشل إشعار Ntfy التجريبي',
'settings.ntfyUrl.clearToken': 'مسح',
'settings.ntfyUrl.tokenCleared': 'تم مسح رمز الوصول',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
@@ -1841,16 +1857,19 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminWebhookPanel.testFailed': 'فشل إرسال Webhook الاختباري',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'يُرسل Webhook المسؤول تلقائيًا عند تعيين رابط URL',
'admin.notifications.ntfy': 'Ntfy',
'admin.ntfy.hint': 'تسمح للمستخدمين بإعداد موضوعات ntfy الخاصة لتلقي إشعارات الدفع. قم بتعيين الخادم الافتراضي أدناه لملء إعدادات المستخدم مسبقًا.',
'admin.notifications.testNtfy': 'إرسال Ntfy تجريبي',
'admin.notifications.testNtfySuccess': 'تم إرسال Ntfy التجريبي بنجاح',
'admin.notifications.testNtfyFailed': 'فشل إرسال Ntfy التجريبي',
'admin.notifications.adminNtfyPanel.title': 'Ntfy المسؤول',
'admin.notifications.adminNtfyPanel.hint': 'يُستخدم موضوع Ntfy هذا حصريًا لإشعارات المسؤول (مثل تنبيهات الإصدارات). وهو مستقل عن مواضيع المستخدمين ويُرسل دائمًا عند تهيئته.',
'admin.notifications.adminNtfyPanel.serverLabel': 'عنوان URL خادم Ntfy',
'admin.notifications.adminNtfyPanel.serverHint': 'يُستخدم أيضًا كخادم افتراضي لإشعارات ntfy للمستخدمين. اتركه فارغًا لاستخدام ntfy.sh. يمكن للمستخدمين تغييره في إعداداتهم الخاصة.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'موضوع المسؤول',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': 'رمز الوصول (اختياري)',
'admin.notifications.adminNtfyPanel.tokenCleared': 'تم مسح رمز وصول المسؤول',
'admin.notifications.adminNtfyPanel.saved': 'تم حفظ إعدادات Ntfy للمسؤول',
'admin.notifications.adminNtfyPanel.test': 'إرسال Ntfy تجريبي',
'admin.notifications.adminNtfyPanel.testSuccess': 'تم إرسال Ntfy التجريبي بنجاح',
@@ -1960,6 +1979,46 @@ 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': 'مجموعة أدوات وإرشادات موسعة',
}
export default ar
+60 -1
View File
@@ -4,6 +4,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'common.showMore': 'Mostrar mais',
'common.showLess': 'Mostrar menos',
'common.cancel': 'Cancelar',
'common.clear': 'Limpar',
'common.delete': 'Excluir',
'common.edit': 'Editar',
'common.add': 'Adicionar',
@@ -547,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',
@@ -975,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',
@@ -1459,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',
@@ -1773,7 +1790,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'settings.ntfyUrl.test': 'Testar',
'settings.ntfyUrl.testSuccess': 'Notificação de teste do Ntfy enviada com sucesso',
'settings.ntfyUrl.testFailed': 'Falha na notificação de teste do Ntfy',
'settings.ntfyUrl.clearToken': 'Limpar',
'settings.ntfyUrl.tokenCleared': 'Token de acesso removido',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
@@ -1790,16 +1806,19 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminWebhookPanel.testFailed': 'Falha no webhook de teste',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'O webhook de admin dispara automaticamente quando uma URL está configurada',
'admin.notifications.ntfy': 'Ntfy',
'admin.ntfy.hint': 'Permite que os usuários configurem seus próprios tópicos ntfy para notificações push. Configure o servidor padrão abaixo para preencher as configurações do usuário.',
'admin.notifications.testNtfy': 'Enviar Ntfy de teste',
'admin.notifications.testNtfySuccess': 'Ntfy de teste enviado com sucesso',
'admin.notifications.testNtfyFailed': 'Falha ao enviar Ntfy de teste',
'admin.notifications.adminNtfyPanel.title': 'Ntfy de admin',
'admin.notifications.adminNtfyPanel.hint': 'Este tópico Ntfy é usado exclusivamente para notificações de admin (ex. alertas de versão). É independente dos tópicos por usuário e sempre dispara quando configurado.',
'admin.notifications.adminNtfyPanel.serverLabel': 'URL do servidor Ntfy',
'admin.notifications.adminNtfyPanel.serverHint': 'Também usado como servidor padrão para notificações ntfy dos usuários. Deixe em branco para usar ntfy.sh. Os usuários podem substituir isso em suas próprias configurações.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'Tópico de admin',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': 'Token de acesso (opcional)',
'admin.notifications.adminNtfyPanel.tokenCleared': 'Token de acesso admin removido',
'admin.notifications.adminNtfyPanel.saved': 'Configurações de Ntfy de admin salvas',
'admin.notifications.adminNtfyPanel.test': 'Enviar Ntfy de teste',
'admin.notifications.adminNtfyPanel.testSuccess': 'Ntfy de teste enviado com sucesso',
@@ -2163,6 +2182,46 @@ 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',
}
export default br
+60 -1
View File
@@ -4,6 +4,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'common.showMore': 'Zobrazit více',
'common.showLess': 'Zobrazit méně',
'common.cancel': 'Zrušit',
'common.clear': 'Vymazat',
'common.delete': 'Smazat',
'common.edit': 'Upravit',
'common.add': 'Přidat',
@@ -547,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',
@@ -1004,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',
@@ -1488,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í',
@@ -1778,7 +1795,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'settings.ntfyUrl.test': 'Otestovat',
'settings.ntfyUrl.testSuccess': 'Testovací notifikace Ntfy byla úspěšně odeslána',
'settings.ntfyUrl.testFailed': 'Testovací notifikace Ntfy selhala',
'settings.ntfyUrl.clearToken': 'Vymazat',
'settings.ntfyUrl.tokenCleared': 'Přístupový token byl vymazán',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
@@ -1795,16 +1811,19 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminWebhookPanel.testFailed': 'Testovací webhook selhal',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin webhook odesílá automaticky, pokud je nastavena URL',
'admin.notifications.ntfy': 'Ntfy',
'admin.ntfy.hint': 'Umožňuje uživatelům nakonfigurovat vlastní témata ntfy pro přijímání push notifikací. Níže nastavte výchozí server pro předvyplnění nastavení uživatelů.',
'admin.notifications.testNtfy': 'Odeslat testovací Ntfy',
'admin.notifications.testNtfySuccess': 'Testovací Ntfy bylo úspěšně odesláno',
'admin.notifications.testNtfyFailed': 'Testovací Ntfy selhalo',
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
'admin.notifications.adminNtfyPanel.hint': 'Toto téma Ntfy se používá výhradně pro admin oznámení (např. upozornění na verze). Je nezávislé na tématech uživatelů a odesílá vždy, když je nakonfigurováno.',
'admin.notifications.adminNtfyPanel.serverLabel': 'URL serveru Ntfy',
'admin.notifications.adminNtfyPanel.serverHint': 'Slouží také jako výchozí server pro ntfy notifikace uživatelů. Ponechte prázdné pro použití ntfy.sh. Uživatelé si to mohou změnit ve vlastním nastavení.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin téma',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': 'Přístupový token (volitelné)',
'admin.notifications.adminNtfyPanel.tokenCleared': 'Přístupový token admina byl vymazán',
'admin.notifications.adminNtfyPanel.saved': 'Nastavení admin Ntfy uloženo',
'admin.notifications.adminNtfyPanel.test': 'Odeslat testovací Ntfy',
'admin.notifications.adminNtfyPanel.testSuccess': 'Testovací Ntfy bylo úspěšně odesláno',
@@ -2167,6 +2186,46 @@ 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ů',
}
export default cs
+60 -1
View File
@@ -4,6 +4,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'common.showMore': 'Mehr anzeigen',
'common.showLess': 'Weniger anzeigen',
'common.cancel': 'Abbrechen',
'common.clear': 'Löschen',
'common.delete': 'Löschen',
'common.edit': 'Bearbeiten',
'common.add': 'Hinzufügen',
@@ -551,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',
@@ -1006,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',
@@ -1490,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',
@@ -1781,7 +1798,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'settings.ntfyUrl.test': 'Testen',
'settings.ntfyUrl.testSuccess': 'Test-Ntfy-Benachrichtigung erfolgreich gesendet',
'settings.ntfyUrl.testFailed': 'Test-Ntfy-Benachrichtigung fehlgeschlagen',
'settings.ntfyUrl.clearToken': 'Löschen',
'settings.ntfyUrl.tokenCleared': 'Zugriffstoken gelöscht',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
@@ -1798,16 +1814,19 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminWebhookPanel.testFailed': 'Test-Webhook fehlgeschlagen',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin-Webhook sendet automatisch, wenn eine URL konfiguriert ist',
'admin.notifications.ntfy': 'Ntfy',
'admin.ntfy.hint': 'Erlaubt Benutzern, eigene ntfy-Themen für Push-Benachrichtigungen zu konfigurieren. Legen Sie unten den Standardserver fest, um die Benutzereinstellungen vorauszufüllen.',
'admin.notifications.testNtfy': 'Test-Ntfy senden',
'admin.notifications.testNtfySuccess': 'Test-Ntfy erfolgreich gesendet',
'admin.notifications.testNtfyFailed': 'Test-Ntfy fehlgeschlagen',
'admin.notifications.adminNtfyPanel.title': 'Admin-Ntfy',
'admin.notifications.adminNtfyPanel.hint': 'Dieses Ntfy-Thema wird ausschließlich für Admin-Benachrichtigungen verwendet (z. B. Versions-Updates). Es ist unabhängig von Benutzer-Themen und sendet immer, wenn es konfiguriert ist.',
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy-Server-URL',
'admin.notifications.adminNtfyPanel.serverHint': 'Wird auch als Standardserver für Benutzer-ntfy-Benachrichtigungen verwendet. Leer lassen für ntfy.sh. Benutzer können dies in ihren eigenen Einstellungen überschreiben.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin-Thema',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': 'Zugriffstoken (optional)',
'admin.notifications.adminNtfyPanel.tokenCleared': 'Admin-Zugriffstoken gelöscht',
'admin.notifications.adminNtfyPanel.saved': 'Admin-Ntfy-Einstellungen gespeichert',
'admin.notifications.adminNtfyPanel.test': 'Test-Ntfy senden',
'admin.notifications.adminNtfyPanel.testSuccess': 'Test-Ntfy erfolgreich gesendet',
@@ -2167,6 +2186,46 @@ 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',
}
export default de
+61 -1
View File
@@ -4,6 +4,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'common.showMore': 'Show more',
'common.showLess': 'Show less',
'common.cancel': 'Cancel',
'common.clear': 'Clear',
'common.delete': 'Delete',
'common.edit': 'Edit',
'common.add': 'Add',
@@ -209,7 +210,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.ntfyUrl.test': 'Test',
'settings.ntfyUrl.testSuccess': 'Test ntfy notification sent successfully',
'settings.ntfyUrl.testFailed': 'Test ntfy notification failed',
'settings.ntfyUrl.clearToken': 'Clear',
'settings.ntfyUrl.tokenCleared': 'Access token cleared',
'admin.notifications.title': 'Notifications',
'admin.notifications.hint': 'Choose one notification channel. Only one can be active at a time.',
@@ -217,6 +217,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.email': 'Email (SMTP)',
'admin.notifications.webhook': 'Webhook',
'admin.notifications.ntfy': 'Ntfy',
'admin.ntfy.hint': 'Allow users to configure their own ntfy topics for push notifications. Set the default server below to pre-fill user settings.',
'admin.notifications.save': 'Save notification settings',
'admin.notifications.saved': 'Notification settings saved',
'admin.notifications.testWebhook': 'Send test webhook',
@@ -238,10 +239,12 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
'admin.notifications.adminNtfyPanel.hint': 'This ntfy topic is used exclusively for admin notifications (e.g. version alerts). It is separate from per-user topics and always fires when configured.',
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy Server URL',
'admin.notifications.adminNtfyPanel.serverHint': 'Also used as the default server for user ntfy notifications. Leave blank to default to ntfy.sh. Users can override this in their own settings.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin Topic',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': 'Access Token (optional)',
'admin.notifications.adminNtfyPanel.tokenCleared': 'Admin access token cleared',
'admin.notifications.adminNtfyPanel.saved': 'Admin ntfy settings saved',
'admin.notifications.adminNtfyPanel.test': 'Send test ntfy',
'admin.notifications.adminNtfyPanel.testSuccess': 'Test ntfy sent successfully',
@@ -605,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',
@@ -1057,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',
@@ -1541,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',
@@ -2203,6 +2222,47 @@ 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 — 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
+60 -1
View File
@@ -4,6 +4,7 @@ const es: Record<string, string> = {
'common.showMore': 'Ver más',
'common.showLess': 'Ver menos',
'common.cancel': 'Cancelar',
'common.clear': 'Borrar',
'common.delete': 'Eliminar',
'common.edit': 'Editar',
'common.add': 'Añadir',
@@ -542,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',
@@ -1439,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',
@@ -1606,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',
@@ -1783,7 +1800,6 @@ const es: Record<string, string> = {
'settings.ntfyUrl.test': 'Probar',
'settings.ntfyUrl.testSuccess': 'Notificación de prueba de Ntfy enviada correctamente',
'settings.ntfyUrl.testFailed': 'Error en la notificación de prueba de Ntfy',
'settings.ntfyUrl.clearToken': 'Borrar',
'settings.ntfyUrl.tokenCleared': 'Token de acceso eliminado',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
@@ -1800,16 +1816,19 @@ const es: Record<string, string> = {
'admin.notifications.adminWebhookPanel.testFailed': 'Error al enviar el webhook de prueba',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'El webhook de admin se activa automáticamente si hay una URL configurada',
'admin.notifications.ntfy': 'Ntfy',
'admin.ntfy.hint': 'Permite a los usuarios configurar sus propios temas ntfy para notificaciones push. Establece el servidor predeterminado a continuación para rellenar automáticamente los ajustes del usuario.',
'admin.notifications.testNtfy': 'Enviar Ntfy de prueba',
'admin.notifications.testNtfySuccess': 'Ntfy de prueba enviado correctamente',
'admin.notifications.testNtfyFailed': 'Error al enviar el Ntfy de prueba',
'admin.notifications.adminNtfyPanel.title': 'Ntfy de admin',
'admin.notifications.adminNtfyPanel.hint': 'Este tema Ntfy se usa exclusivamente para notificaciones de admin (ej. alertas de versión). Es independiente de los temas por usuario y siempre se activa cuando está configurado.',
'admin.notifications.adminNtfyPanel.serverLabel': 'URL del servidor Ntfy',
'admin.notifications.adminNtfyPanel.serverHint': 'También se usa como servidor predeterminado para las notificaciones ntfy de los usuarios. Déjalo en blanco para usar ntfy.sh. Los usuarios pueden cambiarlo en sus propios ajustes.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'Tema de admin',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': 'Token de acceso (opcional)',
'admin.notifications.adminNtfyPanel.tokenCleared': 'Token de acceso de admin eliminado',
'admin.notifications.adminNtfyPanel.saved': 'Configuración de Ntfy de admin guardada',
'admin.notifications.adminNtfyPanel.test': 'Enviar Ntfy de prueba',
'admin.notifications.adminNtfyPanel.testSuccess': 'Ntfy de prueba enviado correctamente',
@@ -2169,6 +2188,46 @@ 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',
}
export default es
+60 -1
View File
@@ -4,6 +4,7 @@ const fr: Record<string, string> = {
'common.showMore': 'Voir plus',
'common.showLess': 'Voir moins',
'common.cancel': 'Annuler',
'common.clear': 'Effacer',
'common.delete': 'Supprimer',
'common.edit': 'Modifier',
'common.add': 'Ajouter',
@@ -546,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',
@@ -1002,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',
@@ -1486,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',
@@ -1777,7 +1794,6 @@ const fr: Record<string, string> = {
'settings.ntfyUrl.test': 'Tester',
'settings.ntfyUrl.testSuccess': 'Notification de test Ntfy envoyée avec succès',
'settings.ntfyUrl.testFailed': 'Échec de la notification de test Ntfy',
'settings.ntfyUrl.clearToken': 'Effacer',
'settings.ntfyUrl.tokenCleared': "Jeton d'accès effacé",
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
@@ -1794,16 +1810,19 @@ const fr: Record<string, string> = {
'admin.notifications.adminWebhookPanel.testFailed': 'Échec du webhook de test',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Le webhook admin s\'active automatiquement si une URL est configurée',
'admin.notifications.ntfy': 'Ntfy',
'admin.ntfy.hint': 'Permet aux utilisateurs de configurer leurs propres sujets ntfy pour les notifications push. Définissez le serveur par défaut ci-dessous pour pré-remplir les paramètres utilisateur.',
'admin.notifications.testNtfy': 'Envoyer un Ntfy de test',
'admin.notifications.testNtfySuccess': 'Ntfy de test envoyé avec succès',
'admin.notifications.testNtfyFailed': 'Échec de l\'envoi du Ntfy de test',
'admin.notifications.adminNtfyPanel.title': 'Ntfy admin',
'admin.notifications.adminNtfyPanel.hint': 'Ce sujet Ntfy est utilisé exclusivement pour les notifications admin (ex. alertes de version). Il est séparé des sujets par utilisateur et s\'active toujours lorsqu\'il est configuré.',
'admin.notifications.adminNtfyPanel.serverLabel': 'URL du serveur Ntfy',
'admin.notifications.adminNtfyPanel.serverHint': 'Utilisé également comme serveur par défaut pour les notifications ntfy des utilisateurs. Laisser vide pour utiliser ntfy.sh. Les utilisateurs peuvent le modifier dans leurs propres paramètres.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'Sujet admin',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': "Jeton d'accès (optionnel)",
'admin.notifications.adminNtfyPanel.tokenCleared': "Jeton d'accès admin effacé",
'admin.notifications.adminNtfyPanel.saved': 'Paramètres Ntfy admin enregistrés',
'admin.notifications.adminNtfyPanel.test': 'Envoyer un Ntfy de test',
'admin.notifications.adminNtfyPanel.testSuccess': 'Ntfy de test envoyé avec succès',
@@ -2163,6 +2182,46 @@ 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',
}
export default fr
+60 -1
View File
@@ -4,6 +4,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'common.showMore': 'Továbbiak',
'common.showLess': 'Kevesebb',
'common.cancel': 'Mégse',
'common.clear': 'Törlés',
'common.delete': 'Törlés',
'common.edit': 'Szerkesztés',
'common.add': 'Hozzáadás',
@@ -547,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',
@@ -1004,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',
@@ -1487,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',
@@ -1775,7 +1792,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'settings.ntfyUrl.test': 'Teszt',
'settings.ntfyUrl.testSuccess': 'Teszt Ntfy értesítés sikeresen elküldve',
'settings.ntfyUrl.testFailed': 'Teszt Ntfy értesítés sikertelen',
'settings.ntfyUrl.clearToken': 'Törlés',
'settings.ntfyUrl.tokenCleared': 'Hozzáférési token törölve',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
@@ -1792,16 +1808,19 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminWebhookPanel.testFailed': 'Teszt webhook sikertelen',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Az admin webhook automatikusan küld, ha URL van beállítva',
'admin.notifications.ntfy': 'Ntfy',
'admin.ntfy.hint': 'Lehetővé teszi a felhasználóknak, hogy saját ntfy-témáikat konfigurálják push értesítésekhez. Állítsa be az alapértelmezett szervert alább a felhasználói beállítások előre kitöltéséhez.',
'admin.notifications.testNtfy': 'Teszt Ntfy küldése',
'admin.notifications.testNtfySuccess': 'Teszt Ntfy sikeresen elküldve',
'admin.notifications.testNtfyFailed': 'Teszt Ntfy sikertelen',
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
'admin.notifications.adminNtfyPanel.hint': 'Ez az Ntfy téma kizárólag admin értesítésekhez használatos (pl. verziófrissítési figyelmeztetések). Független a felhasználói témáktól, és mindig küld, ha konfigurálva van.',
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy szerver URL',
'admin.notifications.adminNtfyPanel.serverHint': 'Alapértelmezett szerverként is szolgál a felhasználói ntfy értesítésekhez. Üresen hagyva ntfy.sh-t használ. A felhasználók felülírhatják saját beállításaikban.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin téma',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': 'Hozzáférési token (opcionális)',
'admin.notifications.adminNtfyPanel.tokenCleared': 'Admin hozzáférési token törölve',
'admin.notifications.adminNtfyPanel.saved': 'Admin Ntfy beállítások mentve',
'admin.notifications.adminNtfyPanel.test': 'Teszt Ntfy küldése',
'admin.notifications.adminNtfyPanel.testSuccess': 'Teszt Ntfy sikeresen elküldve',
@@ -2164,6 +2183,46 @@ 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',
}
export default hu
+60 -1
View File
@@ -4,6 +4,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'common.showMore': 'Tampilkan lebih banyak',
'common.showLess': 'Tampilkan lebih sedikit',
'common.cancel': 'Batal',
'common.clear': 'Hapus',
'common.delete': 'Hapus',
'common.edit': 'Sunting',
'common.add': 'Tambah',
@@ -209,7 +210,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'settings.ntfyUrl.test': 'Uji',
'settings.ntfyUrl.testSuccess': 'Notifikasi uji Ntfy berhasil dikirim',
'settings.ntfyUrl.testFailed': 'Notifikasi uji Ntfy gagal',
'settings.ntfyUrl.clearToken': 'Hapus',
'settings.ntfyUrl.tokenCleared': 'Token akses dihapus',
'admin.notifications.title': 'Notifikasi',
'admin.notifications.hint': 'Pilih satu saluran notifikasi. Hanya satu yang bisa aktif sekaligus.',
@@ -232,16 +232,19 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminWebhookPanel.testFailed': 'Test webhook gagal',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin webhook selalu berjalan jika URL dikonfigurasi',
'admin.notifications.ntfy': 'Ntfy',
'admin.ntfy.hint': 'Memungkinkan pengguna mengonfigurasi topik ntfy mereka sendiri untuk notifikasi push. Tetapkan server default di bawah untuk mengisi setelan pengguna secara otomatis.',
'admin.notifications.testNtfy': 'Kirim uji Ntfy',
'admin.notifications.testNtfySuccess': 'Uji Ntfy berhasil dikirim',
'admin.notifications.testNtfyFailed': 'Uji Ntfy gagal',
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
'admin.notifications.adminNtfyPanel.hint': 'Topik Ntfy ini digunakan khusus untuk notifikasi admin (mis. peringatan versi). Terpisah dari topik per pengguna dan selalu berjalan jika dikonfigurasi.',
'admin.notifications.adminNtfyPanel.serverLabel': 'URL Server Ntfy',
'admin.notifications.adminNtfyPanel.serverHint': 'Juga digunakan sebagai server default untuk notifikasi ntfy pengguna. Kosongkan untuk menggunakan ntfy.sh. Pengguna dapat menggantinya di pengaturan mereka sendiri.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'Topik Admin',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': 'Token Akses (opsional)',
'admin.notifications.adminNtfyPanel.tokenCleared': 'Token akses admin dihapus',
'admin.notifications.adminNtfyPanel.saved': 'Pengaturan Ntfy admin tersimpan',
'admin.notifications.adminNtfyPanel.test': 'Kirim uji Ntfy',
'admin.notifications.adminNtfyPanel.testSuccess': 'Uji Ntfy berhasil dikirim',
@@ -605,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',
@@ -1057,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',
@@ -1541,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',
@@ -2205,6 +2224,46 @@ 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',
};
export default id;
+60 -1
View File
@@ -4,6 +4,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'common.showMore': 'Mostra di più',
'common.showLess': 'Mostra meno',
'common.cancel': 'Annulla',
'common.clear': 'Cancella',
'common.delete': 'Elimina',
'common.edit': 'Modifica',
'common.add': 'Aggiungi',
@@ -546,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',
@@ -1003,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',
@@ -1487,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',
@@ -1778,7 +1795,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'settings.ntfyUrl.test': 'Testa',
'settings.ntfyUrl.testSuccess': 'Notifica di test Ntfy inviata con successo',
'settings.ntfyUrl.testFailed': 'Notifica di test Ntfy fallita',
'settings.ntfyUrl.clearToken': 'Cancella',
'settings.ntfyUrl.tokenCleared': 'Token di accesso rimosso',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
@@ -1795,16 +1811,19 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminWebhookPanel.testFailed': 'Invio webhook di test fallito',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Il webhook admin si attiva automaticamente quando è configurato un URL',
'admin.notifications.ntfy': 'Ntfy',
'admin.ntfy.hint': 'Consente agli utenti di configurare i propri argomenti ntfy per le notifiche push. Imposta il server predefinito di seguito per precompilare le impostazioni utente.',
'admin.notifications.testNtfy': 'Invia Ntfy di test',
'admin.notifications.testNtfySuccess': 'Ntfy di test inviato con successo',
'admin.notifications.testNtfyFailed': 'Invio Ntfy di test fallito',
'admin.notifications.adminNtfyPanel.title': 'Ntfy admin',
'admin.notifications.adminNtfyPanel.hint': 'Questo argomento Ntfy viene usato esclusivamente per le notifiche admin (es. avvisi di versione). È separato dagli argomenti per utente e si attiva sempre quando è configurato.',
'admin.notifications.adminNtfyPanel.serverLabel': 'URL server Ntfy',
'admin.notifications.adminNtfyPanel.serverHint': 'Usato anche come server predefinito per le notifiche ntfy degli utenti. Lasciare vuoto per usare ntfy.sh. Gli utenti possono sovrascriverlo nelle proprie impostazioni.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'Argomento admin',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': 'Token di accesso (opzionale)',
'admin.notifications.adminNtfyPanel.tokenCleared': 'Token di accesso admin rimosso',
'admin.notifications.adminNtfyPanel.saved': 'Impostazioni Ntfy admin salvate',
'admin.notifications.adminNtfyPanel.test': 'Invia Ntfy di test',
'admin.notifications.adminNtfyPanel.testSuccess': 'Ntfy di test inviato con successo',
@@ -2164,6 +2183,46 @@ 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',
}
export default it
+60 -1
View File
@@ -4,6 +4,7 @@ const nl: Record<string, string> = {
'common.showMore': 'Meer tonen',
'common.showLess': 'Minder tonen',
'common.cancel': 'Annuleren',
'common.clear': 'Wissen',
'common.delete': 'Verwijderen',
'common.edit': 'Bewerken',
'common.add': 'Toevoegen',
@@ -547,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',
@@ -1002,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',
@@ -1486,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',
@@ -1777,7 +1794,6 @@ const nl: Record<string, string> = {
'settings.ntfyUrl.test': 'Testen',
'settings.ntfyUrl.testSuccess': 'Test-Ntfy-melding succesvol verzonden',
'settings.ntfyUrl.testFailed': 'Test-Ntfy-melding mislukt',
'settings.ntfyUrl.clearToken': 'Wissen',
'settings.ntfyUrl.tokenCleared': 'Toegangstoken gewist',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
@@ -1794,16 +1810,19 @@ const nl: Record<string, string> = {
'admin.notifications.adminWebhookPanel.testFailed': 'Test-webhook mislukt',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin-webhook verstuurt automatisch als er een URL is ingesteld',
'admin.notifications.ntfy': 'Ntfy',
'admin.ntfy.hint': 'Hiermee kunnen gebruikers hun eigen ntfy-onderwerpen instellen voor pushmeldingen. Stel de standaardserver hieronder in om de gebruikersinstellingen vooraf in te vullen.',
'admin.notifications.testNtfy': 'Test-Ntfy verzenden',
'admin.notifications.testNtfySuccess': 'Test-Ntfy succesvol verzonden',
'admin.notifications.testNtfyFailed': 'Test-Ntfy mislukt',
'admin.notifications.adminNtfyPanel.title': 'Admin-Ntfy',
'admin.notifications.adminNtfyPanel.hint': 'Dit Ntfy-onderwerp wordt uitsluitend gebruikt voor admin-meldingen (bijv. versie-updates). Het staat los van onderwerpen per gebruiker en verstuurt altijd wanneer het geconfigureerd is.',
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy-server-URL',
'admin.notifications.adminNtfyPanel.serverHint': 'Wordt ook gebruikt als standaardserver voor ntfy-meldingen van gebruikers. Laat leeg om ntfy.sh te gebruiken. Gebruikers kunnen dit aanpassen in hun eigen instellingen.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin-onderwerp',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': 'Toegangstoken (optioneel)',
'admin.notifications.adminNtfyPanel.tokenCleared': 'Admin-toegangstoken gewist',
'admin.notifications.adminNtfyPanel.saved': 'Admin-Ntfy-instellingen opgeslagen',
'admin.notifications.adminNtfyPanel.test': 'Test-Ntfy verzenden',
'admin.notifications.adminNtfyPanel.testSuccess': 'Test-Ntfy succesvol verzonden',
@@ -2163,6 +2182,46 @@ 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',
}
export default nl
+60 -1
View File
@@ -4,6 +4,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'common.showMore': 'Pokaż więcej',
'common.showLess': 'Pokaż mniej',
'common.cancel': 'Anuluj',
'common.clear': 'Wyczyść',
'common.delete': 'Usuń',
'common.edit': 'Edytuj',
'common.add': 'Dodaj',
@@ -519,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',
@@ -959,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',
@@ -1441,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',
@@ -1597,16 +1614,19 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminWebhookPanel.testFailed': 'Wysyłanie testowego webhooka nie powiodło się',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Webhook admina wysyła automatycznie, gdy URL jest skonfigurowany',
'admin.notifications.ntfy': 'Ntfy',
'admin.ntfy.hint': 'Pozwala użytkownikom skonfigurować własne tematy ntfy dla powiadomień push. Ustaw domyślny serwer poniżej, aby wstępnie wypełnić ustawienia użytkownika.',
'admin.notifications.testNtfy': 'Wyślij testowe Ntfy',
'admin.notifications.testNtfySuccess': 'Testowe Ntfy wysłane pomyślnie',
'admin.notifications.testNtfyFailed': 'Wysyłanie testowego Ntfy nie powiodło się',
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
'admin.notifications.adminNtfyPanel.hint': 'Ten temat Ntfy jest używany wyłącznie do powiadomień admina (np. alertów o wersjach). Jest niezależny od tematów użytkowników i zawsze wysyła po skonfigurowaniu.',
'admin.notifications.adminNtfyPanel.serverLabel': 'URL serwera Ntfy',
'admin.notifications.adminNtfyPanel.serverHint': 'Używany również jako domyślny serwer dla powiadomień ntfy użytkowników. Pozostaw puste, aby użyć ntfy.sh. Użytkownicy mogą to nadpisać w swoich ustawieniach.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'Temat admina',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': 'Token dostępu (opcjonalne)',
'admin.notifications.adminNtfyPanel.tokenCleared': 'Token dostępu admina wyczyszczony',
'admin.notifications.adminNtfyPanel.saved': 'Ustawienia admin Ntfy zapisane',
'admin.notifications.adminNtfyPanel.test': 'Wyślij testowe Ntfy',
'admin.notifications.adminNtfyPanel.testSuccess': 'Testowe Ntfy wysłane pomyślnie',
@@ -1634,7 +1654,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'settings.ntfyUrl.test': 'Testuj',
'settings.ntfyUrl.testSuccess': 'Testowe powiadomienie Ntfy wysłane pomyślnie',
'settings.ntfyUrl.testFailed': 'Testowe powiadomienie Ntfy nie powiodło się',
'settings.ntfyUrl.clearToken': 'Wyczyść',
'settings.ntfyUrl.tokenCleared': 'Token dostępu wyczyszczony',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
@@ -2156,6 +2175,46 @@ 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',
}
export default pl
+60 -1
View File
@@ -4,6 +4,7 @@ const ru: Record<string, string> = {
'common.showMore': 'Показать больше',
'common.showLess': 'Показать меньше',
'common.cancel': 'Отмена',
'common.clear': 'Очистить',
'common.delete': 'Удалить',
'common.edit': 'Редактировать',
'common.add': 'Добавить',
@@ -547,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': 'Создавайте многоразовые списки вещей для поездок',
@@ -1002,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': 'Привязать к жилью',
@@ -1486,6 +1502,7 @@ const ru: Record<string, string> = {
'day.noPlacesForHotel': 'Сначала добавьте места в поездку',
'day.allDays': 'Все',
'day.checkIn': 'Заезд',
'day.checkInUntil': 'До',
'day.checkOut': 'Выезд',
'day.confirmation': 'Подтверждение',
'day.editAccommodation': 'Редактировать жильё',
@@ -1774,7 +1791,6 @@ const ru: Record<string, string> = {
'settings.ntfyUrl.test': 'Тест',
'settings.ntfyUrl.testSuccess': 'Тестовое уведомление Ntfy успешно отправлено',
'settings.ntfyUrl.testFailed': 'Ошибка отправки тестового уведомления Ntfy',
'settings.ntfyUrl.clearToken': 'Очистить',
'settings.ntfyUrl.tokenCleared': 'Токен доступа очищен',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
@@ -1791,16 +1807,19 @@ const ru: Record<string, string> = {
'admin.notifications.adminWebhookPanel.testFailed': 'Ошибка тестового вебхука',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Вебхук администратора отправляется автоматически при наличии URL',
'admin.notifications.ntfy': 'Ntfy',
'admin.ntfy.hint': 'Позволяет пользователям настраивать собственные темы ntfy для push-уведомлений. Установите сервер по умолчанию ниже, чтобы предварительно заполнить настройки пользователей.',
'admin.notifications.testNtfy': 'Отправить тестовое Ntfy',
'admin.notifications.testNtfySuccess': 'Тестовое Ntfy успешно отправлено',
'admin.notifications.testNtfyFailed': 'Ошибка отправки тестового Ntfy',
'admin.notifications.adminNtfyPanel.title': 'Ntfy администратора',
'admin.notifications.adminNtfyPanel.hint': 'Эта тема Ntfy используется исключительно для уведомлений администратора (например, оповещения о версиях). Она независима от тем пользователей и всегда отправляется при наличии настройки.',
'admin.notifications.adminNtfyPanel.serverLabel': 'URL сервера Ntfy',
'admin.notifications.adminNtfyPanel.serverHint': 'Также используется как сервер по умолчанию для ntfy-уведомлений пользователей. Оставьте пустым, чтобы использовать ntfy.sh. Пользователи могут изменить это в своих настройках.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'Тема администратора',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': 'Токен доступа (необязательно)',
'admin.notifications.adminNtfyPanel.tokenCleared': 'Токен доступа администратора очищен',
'admin.notifications.adminNtfyPanel.saved': 'Настройки Ntfy администратора сохранены',
'admin.notifications.adminNtfyPanel.test': 'Отправить тестовое Ntfy',
'admin.notifications.adminNtfyPanel.testSuccess': 'Тестовое Ntfy успешно отправлено',
@@ -2163,6 +2182,46 @@ 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': 'Расширенный набор инструментов',
}
export default ru
+60 -1
View File
@@ -4,6 +4,7 @@ const zh: Record<string, string> = {
'common.showMore': '显示更多',
'common.showLess': '收起',
'common.cancel': '取消',
'common.clear': '清除',
'common.delete': '删除',
'common.edit': '编辑',
'common.add': '添加',
@@ -547,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': '创建可复用的旅行打包清单',
@@ -1002,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': '关联住宿',
@@ -1486,6 +1502,7 @@ const zh: Record<string, string> = {
'day.noPlacesForHotel': '请先在旅行中添加地点',
'day.allDays': '全部',
'day.checkIn': '入住',
'day.checkInUntil': '截止',
'day.checkOut': '退房',
'day.confirmation': '确认号',
'day.editAccommodation': '编辑住宿',
@@ -1774,7 +1791,6 @@ const zh: Record<string, string> = {
'settings.ntfyUrl.test': '测试',
'settings.ntfyUrl.testSuccess': '测试 Ntfy 通知发送成功',
'settings.ntfyUrl.testFailed': '测试 Ntfy 通知失败',
'settings.ntfyUrl.clearToken': '清除',
'settings.ntfyUrl.tokenCleared': '访问令牌已清除',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.webhook': 'Webhook',
@@ -1791,16 +1807,19 @@ const zh: Record<string, string> = {
'admin.notifications.adminWebhookPanel.testFailed': '测试 Webhook 失败',
'admin.notifications.adminWebhookPanel.alwaysOnHint': '配置 URL 后管理员 Webhook 自动触发',
'admin.notifications.ntfy': 'Ntfy',
'admin.ntfy.hint': '允许用户配置自己的 ntfy 主题以接收推送通知。在下方设置默认服务器以预填充用户设置。',
'admin.notifications.testNtfy': '发送测试 Ntfy',
'admin.notifications.testNtfySuccess': '测试 Ntfy 发送成功',
'admin.notifications.testNtfyFailed': '测试 Ntfy 失败',
'admin.notifications.adminNtfyPanel.title': '管理员 Ntfy',
'admin.notifications.adminNtfyPanel.hint': '此 Ntfy 主题专用于管理员通知(如版本更新提醒)。它与每用户主题相互独立,配置后始终触发。',
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy 服务器 URL',
'admin.notifications.adminNtfyPanel.serverHint': '同时用作用户 ntfy 通知的默认服务器。留空则默认使用 ntfy.sh。用户可在其自己的设置中覆盖此项。',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': '管理员主题',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': '访问令牌(可选)',
'admin.notifications.adminNtfyPanel.tokenCleared': '管理员访问令牌已清除',
'admin.notifications.adminNtfyPanel.saved': '管理员 Ntfy 设置已保存',
'admin.notifications.adminNtfyPanel.test': '发送测试 Ntfy',
'admin.notifications.adminNtfyPanel.testSuccess': '测试 Ntfy 发送成功',
@@ -2163,6 +2182,46 @@ 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': 'MCPOAuth 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': '扩展工具集与提示词',
}
export default zh
+60 -1
View File
@@ -4,6 +4,7 @@ const zhTw: Record<string, string> = {
'common.showMore': '顯示更多',
'common.showLess': '收起',
'common.cancel': '取消',
'common.clear': '清除',
'common.delete': '刪除',
'common.edit': '編輯',
'common.add': '新增',
@@ -206,7 +207,6 @@ const zhTw: Record<string, string> = {
'settings.ntfyUrl.test': '測試',
'settings.ntfyUrl.testSuccess': '測試 Ntfy 通知傳送成功',
'settings.ntfyUrl.testFailed': '測試 Ntfy 通知失敗',
'settings.ntfyUrl.clearToken': '清除',
'settings.ntfyUrl.tokenCleared': '存取權杖已清除',
'settings.notificationsDisabled': '通知尚未配置。請聯絡管理員啟用電子郵件或 Webhook 通知。',
'settings.notificationsActive': '活躍頻道',
@@ -232,16 +232,19 @@ const zhTw: Record<string, string> = {
'admin.notifications.adminWebhookPanel.testFailed': '測試 Webhook 傳送失敗',
'admin.notifications.adminWebhookPanel.alwaysOnHint': '配置 URL 後,管理員 Webhook 始終觸發',
'admin.notifications.ntfy': 'Ntfy',
'admin.ntfy.hint': '允許使用者設定自己的 ntfy 主題以接收推播通知。在下方設定預設伺服器以預先填入使用者設定。',
'admin.notifications.testNtfy': '傳送測試 Ntfy',
'admin.notifications.testNtfySuccess': '測試 Ntfy 傳送成功',
'admin.notifications.testNtfyFailed': '測試 Ntfy 失敗',
'admin.notifications.adminNtfyPanel.title': '管理員 Ntfy',
'admin.notifications.adminNtfyPanel.hint': '此 Ntfy 主題專用於管理員通知(例如版本提醒)。它與每位使用者的主題分開,設定後始終會觸發。',
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy 伺服器 URL',
'admin.notifications.adminNtfyPanel.serverHint': '同時用作使用者 ntfy 通知的預設伺服器。留空則預設使用 ntfy.sh。使用者可在自己的設定中覆寫此項。',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': '管理員主題',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': '存取權杖(選填)',
'admin.notifications.adminNtfyPanel.tokenCleared': '管理員存取權杖已清除',
'admin.notifications.adminNtfyPanel.saved': '管理員 Ntfy 設定已儲存',
'admin.notifications.adminNtfyPanel.test': '傳送測試 Ntfy',
'admin.notifications.adminNtfyPanel.testSuccess': '測試 Ntfy 傳送成功',
@@ -601,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': '建立可複用的旅行打包清單',
@@ -1056,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': '關聯住宿',
@@ -1540,6 +1558,7 @@ const zhTw: Record<string, string> = {
'day.noPlacesForHotel': '請先在旅行中新增地點',
'day.allDays': '全部',
'day.checkIn': '入住',
'day.checkInUntil': '截止',
'day.checkOut': '退房',
'day.confirmation': '確認號',
'day.editAccommodation': '編輯住宿',
@@ -2164,6 +2183,46 @@ 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': 'MCPOAuth 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': '擴展工具集與提示詞',
}
export default zhTw
+36 -7
View File
@@ -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>
)}
@@ -1396,6 +1406,7 @@ export default function AdminPage(): React.ReactElement {
placeholder={t('admin.notifications.adminNtfyPanel.serverPlaceholder')}
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 text-slate-400 mt-1">{t('admin.notifications.adminNtfyPanel.serverHint')}</p>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{t('admin.notifications.adminNtfyPanel.topicLabel')}</label>
@@ -1409,13 +1420,29 @@ export default function AdminPage(): React.ReactElement {
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{t('admin.notifications.adminNtfyPanel.tokenLabel')}</label>
<input
type="password"
value={smtpValues.admin_ntfy_token === '••••••••' ? '' : smtpValues.admin_ntfy_token || ''}
onChange={e => setSmtpValues(prev => ({ ...prev, admin_ntfy_token: e.target.value }))}
placeholder={smtpValues.admin_ntfy_token === '••••••••' ? '••••••••' : ''}
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"
/>
<div className="flex gap-2">
<input
type="password"
value={smtpValues.admin_ntfy_token === '••••••••' ? '' : smtpValues.admin_ntfy_token || ''}
onChange={e => setSmtpValues(prev => ({ ...prev, admin_ntfy_token: e.target.value }))}
placeholder={smtpValues.admin_ntfy_token === '••••••••' ? '••••••••' : ''}
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
{smtpValues.admin_ntfy_token === '••••••••' && (
<button
onClick={async () => {
try {
await authApi.updateAppSettings({ admin_ntfy_token: '' })
setSmtpValues(prev => ({ ...prev, admin_ntfy_token: '' }))
toast.success(t('admin.notifications.adminNtfyPanel.tokenCleared'))
} catch { toast.error(t('common.error')) }
}}
className="px-3 py-2 border border-red-300 text-red-600 rounded-lg text-sm font-medium hover:bg-red-50 transition-colors"
>
{t('common.clear')}
</button>
)}
</div>
</div>
</>
)}
@@ -1476,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>
+9 -1
View File
@@ -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 () => {
+9 -3
View File
@@ -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
@@ -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>
+7
View File
@@ -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');
});
+9
View File
@@ -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
+72
View File
@@ -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);
});
},
}));
+1
View File
@@ -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
+754
View File
@@ -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 |
| 8090 | Major version upgrade notices |
| 5070 | Feature announcements |
| 1040 | 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. |
+12 -13
View File
@@ -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
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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);
+24
View File
@@ -1605,6 +1605,30 @@ 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
)
`);
},
];
if (currentVersion < migrations.length) {
+1
View File
@@ -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,
+1 -1
View File
@@ -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 },
];
+44
View File
@@ -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');
+4 -4
View File
@@ -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);
});
-5
View File
@@ -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' });
+29
View File
@@ -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;
+25
View File
@@ -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() {
+4 -4
View File
@@ -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 {
+11 -6
View File
@@ -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);
@@ -241,9 +241,10 @@ 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));
@@ -89,6 +89,7 @@ export interface PreferencesMatrix {
available_channels: AvailableChannels;
event_types: NotifEventType[];
implemented_combos: Record<NotifEventType, NotifChannel[]>;
defaults?: { ntfyServer: string | null };
}
/**
@@ -152,6 +153,7 @@ export function getPreferencesMatrix(userId: number, userRole: string, scope: 'u
available_channels,
event_types,
implemented_combos: IMPLEMENTED_COMBOS,
...(scope === 'user' && { defaults: { ntfyServer: getAppSetting('admin_ntfy_server') || null } }),
};
}
+3 -3
View File
@@ -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);
}
+6 -6
View File
@@ -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) {
+83 -5
View File
@@ -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 {
+72
View File
@@ -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 };
+127
View File
@@ -0,0 +1,127 @@
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,
},
// ── 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,
},
];
+59
View File
@@ -0,0 +1,59 @@
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 {
return process.env.APP_VERSION || '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;
}
+45
View File
@@ -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'>;
+2
View File
@@ -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;
}
+2
View File
@@ -91,6 +91,8 @@ const RESET_TABLES = [
'notification_channel_preferences',
'notifications',
'audit_log',
// System notices
'user_notice_dismissals',
// User data
'settings',
'mcp_tokens',
@@ -407,7 +407,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 () => {
-15
View File
@@ -525,21 +525,6 @@ describe('Naver list import', () => {
vi.unstubAllGlobals();
});
it('POST /import/naver-list returns 403 when addon is disabled', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare("UPDATE addons SET enabled = 0 WHERE id = 'naver_list_import'").run();
const res = await request(app)
.post(`/api/trips/${trip.id}/places/import/naver-list`)
.set('Cookie', authCookie(user.id))
.send({ url: 'https://naver.me/GYDpx3Wv' });
expect(res.status).toBe(403);
expect(res.body.error).toContain('addon is disabled');
});
it('POST /import/naver-list resolves shortlink, paginates, and creates places', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -0,0 +1,241 @@
/**
* System Notices API integration tests.
* Covers GET /api/system-notices/active and POST /api/system-notices/:id/dismiss.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
// ─────────────────────────────────────────────────────────────────────────────
// Bare in-memory DB — schema applied in beforeAll after mocks register
// ─────────────────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../src/db/database', () => dbMock);
vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { SYSTEM_NOTICES } from '../../src/systemNotices/registry';
import type { SystemNotice } from '../../src/systemNotices/types';
const app: Application = createApp();
// Test notice injected into the registry for notice-specific tests
const TEST_NOTICE: SystemNotice = {
id: 'test-first-login-notice',
display: 'modal',
severity: 'info',
titleKey: 'system_notice.test_first_login_notice.title',
bodyKey: 'system_notice.test_first_login_notice.body',
dismissible: true,
conditions: [{ kind: 'firstLogin' }],
publishedAt: '2026-01-01T00:00:00Z',
priority: 0,
};
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ─────────────────────────────────────────────────────────────────────────────
// GET /api/system-notices/active
// ─────────────────────────────────────────────────────────────────────────────
describe('GET /api/system-notices/active', () => {
it('returns 401 without auth', async () => {
const res = await request(app).get('/api/system-notices/active');
expect(res.status).toBe(401);
});
it('returns empty array for non-first-login user with no applicable notices', async () => {
const { user } = createUser(testDb);
// login_count > 1 means firstLogin condition does not match for any notice
testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id);
const res = await request(app)
.get('/api/system-notices/active')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it('returns firstLogin notice for user with login_count <= 1', async () => {
SYSTEM_NOTICES.push(TEST_NOTICE);
try {
const { user } = createUser(testDb);
// Set login_count to 1 (first login)
testDb.prepare('UPDATE users SET login_count = 1 WHERE id = ?').run(user.id);
const res = await request(app)
.get('/api/system-notices/active')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
// welcome-v1 is also in the registry and matches firstLogin, so at least TEST_NOTICE is present
const testNotice = res.body.find((n: { id: string }) => n.id === TEST_NOTICE.id);
expect(testNotice).toBeDefined();
// DTO should not expose conditions, publishedAt, expiresAt, priority
expect(testNotice.conditions).toBeUndefined();
expect(testNotice.publishedAt).toBeUndefined();
} finally {
const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE);
if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1);
}
});
it('does not return firstLogin notice for user with login_count > 1', async () => {
SYSTEM_NOTICES.push(TEST_NOTICE);
try {
const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id);
const res = await request(app)
.get('/api/system-notices/active')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
} finally {
const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE);
if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1);
}
});
it('filters out dismissed notices', async () => {
SYSTEM_NOTICES.push(TEST_NOTICE);
try {
const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET login_count = 1 WHERE id = ?').run(user.id);
// Dismiss the notice directly in DB
testDb.prepare(
'INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at) VALUES (?, ?, ?)'
).run(user.id, TEST_NOTICE.id, Date.now());
const res = await request(app)
.get('/api/system-notices/active')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
// TEST_NOTICE should be filtered out; welcome-v1 may still appear
const found = res.body.find((n: { id: string }) => n.id === TEST_NOTICE.id);
expect(found).toBeUndefined();
} finally {
const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE);
if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// POST /api/system-notices/:id/dismiss
// ─────────────────────────────────────────────────────────────────────────────
describe('POST /api/system-notices/:id/dismiss', () => {
it('returns 401 without auth', async () => {
const res = await request(app).post('/api/system-notices/test-id/dismiss');
expect(res.status).toBe(401);
});
it('returns 404 for unknown notice id', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/system-notices/nonexistent-id/dismiss')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
expect(res.body.error).toBe('NOTICE_NOT_FOUND');
});
it('returns 204 for valid notice id', async () => {
SYSTEM_NOTICES.push(TEST_NOTICE);
try {
const { user } = createUser(testDb);
const res = await request(app)
.post(`/api/system-notices/${TEST_NOTICE.id}/dismiss`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(204);
} finally {
const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE);
if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1);
}
});
it('is idempotent — second dismiss also returns 204', async () => {
SYSTEM_NOTICES.push(TEST_NOTICE);
try {
const { user } = createUser(testDb);
const first = await request(app)
.post(`/api/system-notices/${TEST_NOTICE.id}/dismiss`)
.set('Cookie', authCookie(user.id));
expect(first.status).toBe(204);
const second = await request(app)
.post(`/api/system-notices/${TEST_NOTICE.id}/dismiss`)
.set('Cookie', authCookie(user.id));
expect(second.status).toBe(204);
} finally {
const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE);
if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1);
}
});
it('dismiss appears in GET /active as filtered out', async () => {
SYSTEM_NOTICES.push(TEST_NOTICE);
try {
const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET login_count = 1 WHERE id = ?').run(user.id);
// Confirm TEST_NOTICE is visible before dismiss
const before = await request(app)
.get('/api/system-notices/active')
.set('Cookie', authCookie(user.id));
expect(before.body.find((n: { id: string }) => n.id === TEST_NOTICE.id)).toBeDefined();
// Dismiss it
await request(app)
.post(`/api/system-notices/${TEST_NOTICE.id}/dismiss`)
.set('Cookie', authCookie(user.id));
// Confirm TEST_NOTICE is gone; other notices (e.g. welcome-v1) may still appear
const after = await request(app)
.get('/api/system-notices/active')
.set('Cookie', authCookie(user.id));
expect(after.status).toBe(200);
expect(after.body.find((n: { id: string }) => n.id === TEST_NOTICE.id)).toBeUndefined();
} finally {
const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE);
if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1);
}
});
});
@@ -0,0 +1,94 @@
import { describe, it, expect } from 'vitest';
import { evaluate } from '../../../src/systemNotices/conditions.js';
import type { SystemNotice } from '../../../src/systemNotices/types.js';
const baseNotice: SystemNotice = {
id: 'test',
display: 'modal',
severity: 'info',
titleKey: 'k.title',
bodyKey: 'k.body',
dismissible: true,
conditions: [],
publishedAt: '2026-01-01T00:00:00Z',
};
const baseCtx = {
user: { login_count: 5, first_seen_version: '1.0.0', role: 'user' },
currentAppVersion: '2.0.0',
now: new Date('2026-06-01T00:00:00Z'),
};
describe('firstLogin', () => {
const notice = { ...baseNotice, conditions: [{ kind: 'firstLogin' as const }] };
it('passes when login_count <= 1', () => {
expect(evaluate(notice, { ...baseCtx, user: { ...baseCtx.user, login_count: 1 } })).toBe(true);
});
it('fails when login_count > 1', () => {
expect(evaluate(notice, baseCtx)).toBe(false);
});
});
describe('existingUserBeforeVersion', () => {
const notice = { ...baseNotice, conditions: [{ kind: 'existingUserBeforeVersion' as const, version: '2.0.0' }] };
it('passes for user with first_seen_version < notice version when current >= notice version', () => {
expect(evaluate(notice, baseCtx)).toBe(true);
});
it('fails for new user (first_seen_version >= notice version)', () => {
expect(evaluate(notice, { ...baseCtx, user: { ...baseCtx.user, first_seen_version: '2.0.0' } })).toBe(false);
});
it('fails when current app version < notice version', () => {
expect(evaluate(notice, { ...baseCtx, currentAppVersion: '1.5.0' })).toBe(false);
});
it('passes when current app version is a prerelease of the notice version', () => {
expect(evaluate(notice, { ...baseCtx, currentAppVersion: '2.0.0-pre.42' })).toBe(true);
});
it('passes when current app version is a prerelease beyond the notice version', () => {
expect(evaluate(notice, { ...baseCtx, currentAppVersion: '2.1.0-pre.1' })).toBe(true);
});
});
describe('dateWindow', () => {
it('passes when now is inside window', () => {
const notice = { ...baseNotice, conditions: [{ kind: 'dateWindow' as const, startsAt: '2026-05-01T00:00:00Z', endsAt: '2026-07-01T00:00:00Z' }] };
expect(evaluate(notice, baseCtx)).toBe(true);
});
it('fails when now is before start', () => {
const notice = { ...baseNotice, conditions: [{ kind: 'dateWindow' as const, startsAt: '2026-07-01T00:00:00Z' }] };
expect(evaluate(notice, baseCtx)).toBe(false);
});
it('passes when no endsAt', () => {
const notice = { ...baseNotice, conditions: [{ kind: 'dateWindow' as const, startsAt: '2026-01-01T00:00:00Z' }] };
expect(evaluate(notice, baseCtx)).toBe(true);
});
});
describe('role', () => {
it('passes for matching role', () => {
const notice = { ...baseNotice, conditions: [{ kind: 'role' as const, roles: ['user'] }] };
expect(evaluate(notice, baseCtx)).toBe(true);
});
it('fails for non-matching role', () => {
const notice = { ...baseNotice, conditions: [{ kind: 'role' as const, roles: ['admin'] }] };
expect(evaluate(notice, baseCtx)).toBe(false);
});
});
describe('AND logic', () => {
it('requires all conditions to pass', () => {
const notice = { ...baseNotice, conditions: [
{ kind: 'firstLogin' as const },
{ kind: 'role' as const, roles: ['user'] },
]};
// login_count=1 passes firstLogin, role=user passes role → true
expect(evaluate(notice, { ...baseCtx, user: { ...baseCtx.user, login_count: 1 } })).toBe(true);
// login_count=2 fails firstLogin → false
expect(evaluate(notice, baseCtx)).toBe(false);
});
});
describe('empty conditions', () => {
it('always passes when conditions array is empty', () => {
expect(evaluate(baseNotice, baseCtx)).toBe(true);
});
});
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { SYSTEM_NOTICES } from '../../../src/systemNotices/registry.js';
/** Collect all actionIds registered via registerNoticeAction() in client source files. */
function collectRegisteredActionIds(): Set<string> {
const clientSrc = path.resolve(__dirname, '../../../../client/src');
const ids = new Set<string>();
const queue = [clientSrc];
while (queue.length) {
const dir = queue.pop()!;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) { queue.push(full); continue; }
if (!entry.name.endsWith('noticeActions.ts') && !entry.name.endsWith('noticeActions.js')) continue;
const src = fs.readFileSync(full, 'utf8');
for (const m of src.matchAll(/registerNoticeAction\(\s*['"]([^'"]+)['"]/g)) {
ids.add(m[1]);
}
}
}
return ids;
}
describe('registry integrity', () => {
it('has no duplicate ids', () => {
const ids = SYSTEM_NOTICES.map(n => n.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('all action CTAs reference a registered actionId', () => {
const registeredActionIds = collectRegisteredActionIds();
const actionCtaIds = SYSTEM_NOTICES
.filter(n => n.cta?.kind === 'action')
.map(n => (n.cta as { actionId: string }).actionId);
for (const id of actionCtaIds) {
expect(registeredActionIds, `actionId "${id}" not found in any client noticeActions.ts`).toContain(id);
}
});
it('all publishedAt are valid ISO dates', () => {
for (const n of SYSTEM_NOTICES) {
expect(() => new Date(n.publishedAt).toISOString()).not.toThrow();
}
});
});