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.
This commit is contained in:
jubnl
2026-04-16 14:36:33 +02:00
parent 3b069bc543
commit 293506217e
46 changed files with 3539 additions and 50 deletions
+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>
}
@@ -0,0 +1,132 @@
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 last 2 shown)', async () => {
const n1 = makeBanner({ id: 'banner-1', titleKey: 'Oldest notice' });
const n2 = makeBanner({ id: 'banner-2', titleKey: 'Second notice' });
const n3 = makeBanner({ id: 'banner-3', titleKey: 'Newest notice' });
await act(async () => {
render(<BannerRenderer notices={[n1, n2, n3]} />);
});
expect(screen.queryByText('Oldest notice')).toBeNull();
expect(screen.getByText('Second notice')).toBeTruthy();
expect(screen.getByText('Newest notice')).toBeTruthy();
});
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);
}
+32
View File
@@ -1969,6 +1969,38 @@ 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',
}
export default ar
+32
View File
@@ -2172,6 +2172,38 @@ 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',
}
export default br
+32
View File
@@ -2176,6 +2176,38 @@ 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',
}
export default cs
+32
View File
@@ -2176,6 +2176,38 @@ 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',
}
export default de
+33
View File
@@ -2212,6 +2212,39 @@ 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 — 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
+32
View File
@@ -2178,6 +2178,38 @@ 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',
}
export default es
+32
View File
@@ -2172,6 +2172,38 @@ 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',
}
export default fr
+32
View File
@@ -2173,6 +2173,38 @@ 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',
}
export default hu
+32
View File
@@ -2214,6 +2214,38 @@ 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',
};
export default id;
+32
View File
@@ -2173,6 +2173,38 @@ 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',
}
export default it
+32
View File
@@ -2172,6 +2172,38 @@ 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',
}
export default nl
+32
View File
@@ -2165,6 +2165,38 @@ 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',
}
export default pl
+32
View File
@@ -2172,6 +2172,38 @@ 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-файлов',
}
export default ru
+32
View File
@@ -2172,6 +2172,38 @@ 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 文件导入地点',
}
export default zh
+32
View File
@@ -2173,6 +2173,38 @@ 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 檔案匯入地點',
}
export default zhTw
+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 () => {
+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');
});
+8
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')
+67
View File
@@ -0,0 +1,67 @@
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;
}
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 });
}
},
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);
});
},
}));