Migrate TREK 3 to NestJS + React 19 with a shared Zod contract layer

Brownfield strangler migration of the backend onto NestJS modules
(auth, trips, days, places, assignments, packing, todo, budget,
reservations, collab, files, photos, journey, share, settings, backup,
oidc, oauth, admin, atlas, vacay, weather, airports, maps, categories,
tags, notifications, system-notices) served through a per-prefix
dispatcher, keeping the existing SQLite/better-sqlite3 DB and JWT
httpOnly cookie auth, with behavioural parity for every route.

Client: React 19 upgrade, "page = wiring container + data hook"
pattern across all pages, per-domain Zustand stores bound to
@trek/shared contracts, and decomposition of the large components
(DayPlanSidebar, PackingListPanel, CollabNotes, FileManager,
MemoriesPanel, PlacesSidebar, CollabChat, SystemNoticeModal,
BudgetPanel, PlaceFormModal, ...) into focused render units backed by
in-file hooks.

Apply the shared global request pipeline (helmet/CSP, CORS, HSTS,
forced HTTPS, the global MFA policy and request logging) to the NestJS
instance as well, so a migrated route is protected identically to the
legacy fallback rather than bypassing it.
This commit is contained in:
Maurice
2026-05-30 02:39:26 +02:00
parent 6d2dd37414
commit fc7d8b5d12
347 changed files with 31278 additions and 10381 deletions
+5
View File
@@ -0,0 +1,5 @@
# Playwright E2E (FE7)
e2e/.tmp/
test-results/
playwright-report/
playwright/.cache/
+42
View File
@@ -0,0 +1,42 @@
import { test as setup, expect } from '@playwright/test'
// Relative to the config dir (client/), matching `storageState` in
// playwright.config.ts. Playwright runs from the client workspace root.
const stateFile = 'e2e/.tmp/state.json'
// Credentials match e2e/server-launch.mjs (ADMIN_EMAIL/ADMIN_PASSWORD). The
// seeded admin is created with must_change_password=1, so the first login goes
// through the forced change-password step before reaching the dashboard.
const EMAIL = 'e2e@trek.local'
const SEED_PW = 'E2eTest12345!'
const NEW_PW = 'E2eChanged12345!'
setup('authenticate the seeded admin (incl. forced password change)', async ({ page }) => {
await page.goto('/login')
await page.locator('input[type="email"]').fill(EMAIL)
await page.locator('input[type="password"]').fill(SEED_PW)
await page.locator('button[type="submit"]').click()
// must_change_password=1 → the change-password step renders two password
// fields (new + confirm). Selector-agnostic of the UI language.
const pw = page.locator('input[type="password"]')
await expect(pw).toHaveCount(2)
await pw.nth(0).fill(NEW_PW)
await pw.nth(1).fill(NEW_PW)
await page.locator('button[type="submit"]').click()
await page.waitForURL('**/dashboard', { timeout: 30_000 })
// Dismiss the first-run "Welcome to TREK" system-notice modal(s). It renders
// asynchronously (after the notices fetch), so wait for it before clicking.
// Dismissal is recorded server-side against this user, so clearing it here
// keeps it cleared for every authenticated flow in the run (shared test DB).
const ok = page.getByRole('button', { name: 'OK', exact: true })
await ok.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => {})
for (let i = 0; i < 8 && (await ok.isVisible().catch(() => false)); i++) {
await ok.click()
await page.waitForTimeout(400)
}
await page.context().storageState({ path: stateFile })
})
+25
View File
@@ -0,0 +1,25 @@
import { test, expect } from '@playwright/test'
// Trip lifecycle (core): from the dashboard, open the new-trip modal, name the
// trip, submit, and confirm it shows up on the dashboard. Exercises the whole
// authenticated stack — dashboard → TripFormModal → POST /api/trips → store →
// re-render — against the real backend + isolated test DB.
test('create a trip and see it on the dashboard', async ({ page }) => {
await page.goto('/dashboard')
// The "+ New Trip" card is always rendered in the default (planned) filter.
await page.locator('.add-trip-card').click()
// Scope to the shared Modal (.modal-backdrop). Its form has no in-form submit
// button (the primary action lives in the footer), so click it explicitly
// rather than pressing Enter. The Create button is the slate primary button;
// Cancel is the bordered one.
const modal = page.locator('.modal-backdrop')
await expect(modal).toBeVisible()
const title = `E2E Trip ${Date.now()}`
await modal.locator('input[type="text"]').first().fill(title)
await modal.getByRole('button', { name: 'Create New Trip' }).click()
await expect(page.getByText(title).first()).toBeVisible({ timeout: 15_000 })
})
+10
View File
@@ -0,0 +1,10 @@
import { test, expect } from '@playwright/test'
// Authenticated smoke: the stored session lands on the dashboard and the
// app chrome (navbar) renders instead of bouncing back to /login.
test('authenticated session reaches the dashboard', async ({ page }) => {
await page.goto('/dashboard')
await expect(page).toHaveURL(/\/dashboard/)
// The shared Navbar shows the TREK brand once authenticated.
await expect(page.getByRole('img', { name: 'TREK' }).first()).toBeVisible()
})
+8
View File
@@ -0,0 +1,8 @@
import { test, expect } from '@playwright/test'
// Infra smoke + first unauthenticated flow: the app boots, the backend is
// reachable through the Vite proxy, and the login screen renders its form.
test('login screen renders with a password field', async ({ page }) => {
await page.goto('/login')
await expect(page.locator('input[type="password"]')).toBeVisible()
})
+43
View File
@@ -0,0 +1,43 @@
// Boots the TREK backend for the Playwright E2E run against a fresh, isolated
// SQLite database. The DB file is deleted first so every run starts clean, then
// the server's own startup seeds a known admin from ADMIN_EMAIL/ADMIN_PASSWORD.
//
// The server is built once and launched as a SINGLE node process (not the
// watch-mode `npm run dev`, which spawns tsc -w + node --watch grandchildren
// that survive Playwright's teardown and then linger on :3001 with stale DB
// state). A single child is killed cleanly when Playwright tears the run down.
import { rmSync } from 'node:fs'
import { spawn, execSync } from 'node:child_process'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const dbFile = path.join(here, '.tmp', 'e2e.db')
const serverDir = path.join(here, '..', '..', 'server')
for (const f of [dbFile, `${dbFile}-wal`, `${dbFile}-shm`]) {
try { rmSync(f, { force: true }) } catch {}
}
// Build once (no watcher) — the resulting process is a single killable node.
execSync('node scripts/build.mjs', { cwd: serverDir, stdio: 'inherit' })
const env = {
...process.env,
TREK_DB_FILE: dbFile,
ADMIN_EMAIL: 'e2e@trek.local',
ADMIN_PASSWORD: 'E2eTest12345!',
PORT: '3001',
NODE_ENV: 'development',
}
const child = spawn(process.execPath, ['--require', 'tsconfig-paths/register', 'dist/index.js'], {
cwd: serverDir,
env,
stdio: 'inherit',
})
const stop = () => { try { child.kill() } catch {} }
process.on('SIGINT', stop)
process.on('SIGTERM', stop)
process.on('exit', stop)
child.on('exit', code => process.exit(code ?? 0))
+23
View File
@@ -0,0 +1,23 @@
import { test, expect } from '@playwright/test'
// Open a trip into the planner: create a trip, open it from the dashboard, and
// confirm the trip planner (TripPlannerPage — the app's largest page) actually
// mounts, proving the day-plan/map shell renders rather than crashing on load.
test('open a trip and land in the planner with a map', async ({ page }) => {
await page.goto('/dashboard')
// Create a trip to open.
await page.locator('.add-trip-card').click()
const modal = page.locator('.modal-backdrop')
await expect(modal).toBeVisible()
const title = `E2E Planner ${Date.now()}`
await modal.locator('input[type="text"]').first().fill(title)
await modal.getByRole('button', { name: 'Create New Trip' }).click()
// Open it from the dashboard.
await page.getByText(title).first().click()
await expect(page).toHaveURL(/\/trips\/\d+/)
// The planner shows a Leaflet map once mounted (past the splash screen).
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
})
+20 -16
View File
@@ -14,12 +14,15 @@
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint .",
"lint:pages": "node scripts/check-page-pattern.mjs",
"e2e": "playwright test",
"e2e:report": "playwright show-report",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
},
"dependencies": {
"@react-pdf/renderer": "^4.5.1",
"@trek/shared": "*",
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
"dexie": "^4.4.2",
"heic-to": "^1.4.2",
@@ -27,11 +30,11 @@
"lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0",
"marked": "^18.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-dropzone": "^14.4.1",
"react-leaflet": "^4.2.1",
"react-leaflet-cluster": "^2.1.0",
"react-leaflet": "^5.0.0",
"react-leaflet-cluster": "^4.1.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.22.2",
"react-window": "^2.2.7",
@@ -43,33 +46,34 @@
"zustand": "^4.5.2"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/leaflet": "^1.9.8",
"@types/react": "^18.2.61",
"@types/react-dom": "^18.2.19",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^3.2.4",
"autoprefixer": "^10.4.18",
"eslint": "^10.2.1",
"eslint-config-flat-gitignore": "^2.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"fake-indexeddb": "^6.2.5",
"jsdom": "^29.0.1",
"msw": "^2.13.0",
"postcss": "^8.4.35",
"prettier": "^3.8.3",
"prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"sharp": "^0.33.0",
"tailwindcss": "^3.4.1",
"typescript": "^6.0.2",
"vite": "^5.1.4",
"vite-plugin-pwa": "^0.21.0",
"vitest": "^3.2.4",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"prettier": "^3.8.3",
"prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"eslint": "^10.2.1",
"eslint-config-flat-gitignore": "^2.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2"
"vitest": "^3.2.4"
}
}
+57
View File
@@ -0,0 +1,57 @@
import { defineConfig, devices } from '@playwright/test'
/**
* E2E harness for TREK's critical user flows (FE7).
*
* Two web servers are orchestrated: the Express/Nest backend on :3001 against an
* isolated throwaway SQLite DB (e2e/server-launch.mjs sets TREK_DB_FILE + seeds a
* known admin), and the Vite dev server on :5173 which proxies /api, /uploads,
* /ws to the backend. Tests run serially against one worker so they share the
* single seeded database deterministically.
*/
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
workers: 1,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
timeout: 45_000,
expect: { timeout: 15_000 },
reporter: [['list']],
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
// Unauthenticated flows (login, register, public share) — no stored session.
{ name: 'public', testMatch: /\.public\.spec\.ts/, use: { ...devices['Desktop Chrome'] } },
// One-time login that persists a session for the authenticated flows.
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'app',
testMatch: /\.spec\.ts/,
testIgnore: /(\.public\.spec\.ts|auth\.setup\.ts)/,
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.tmp/state.json' },
dependencies: ['setup'],
},
],
webServer: [
{
// Always start our own backend (never reuse) so the isolated test DB is
// reset + reseeded on every run, regardless of any stray dev server.
command: 'node e2e/server-launch.mjs',
port: 3001,
reuseExistingServer: false,
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
},
{
command: 'npm run dev',
port: 5173,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
],
})
+44
View File
@@ -0,0 +1,44 @@
// Guards the "Page = wiring container + data hook" convention (see
// src/pages/PATTERN.md). A *Page.tsx default-export component should wire a
// co-located use<Page>() hook into JSX — it must not own state/effects itself.
//
// We scan only the default-export component body (from `export default function`
// up to the next top-level `function` declaration or EOF), so presentational
// sub-components and helper hooks living in the same file are not flagged.
// Context hooks like useTranslation/useParams are fine; the smell is stateful
// logic — useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef.
import { readdirSync, readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const pagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'pages')
const BANNED = ['useState', 'useReducer', 'useEffect', 'useLayoutEffect', 'useMemo', 'useCallback', 'useRef']
const bannedRe = new RegExp(`\\b(${BANNED.join('|')})\\s*\\(`)
const violations = []
for (const file of readdirSync(pagesDir)) {
if (!file.endsWith('Page.tsx') || file.endsWith('.test.tsx')) continue
const src = readFileSync(join(pagesDir, file), 'utf8')
const lines = src.split('\n')
const start = lines.findIndex(l => /export default function/.test(l))
if (start === -1) continue
// The page body ends at the next top-level declaration (a `function` at
// column 0) — everything after that is a sub-component or helper.
let end = lines.length
for (let i = start + 1; i < lines.length; i++) {
if (/^(function |const [A-Z]\w* = )/.test(lines[i])) { end = i; break }
}
for (let i = start; i < end; i++) {
if (bannedRe.test(lines[i])) {
violations.push(`${file}:${i + 1} ${lines[i].trim()}`)
}
}
}
if (violations.length > 0) {
console.error('Page-pattern violations — move this state/effect logic into the page\'s use<Page>() hook:\n')
for (const v of violations) console.error(' ' + v)
console.error(`\n${violations.length} violation(s). See src/pages/PATTERN.md.`)
process.exit(1)
}
console.log('Page pattern OK — no state/effect logic in page containers.')
+133 -72
View File
@@ -1,7 +1,62 @@
import axios, { AxiosInstance } from 'axios'
import type { WeatherResult } from '@trek/shared'
import type { z } from 'zod'
import {
weatherResultSchema, type WeatherResult,
inAppListResultSchema, type InAppListResult,
unreadCountResultSchema, type UnreadCountResult,
type NotificationRespondRequest,
type SettingUpsertRequest, type SettingsBulkRequest,
type JourneyCreateRequest, type JourneyAddTripRequest,
type JourneyReorderEntriesRequest, type JourneyProviderPhotosRequest,
type JourneyShareLinkRequest,
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
type ResetPasswordRequest, type ChangePasswordRequest,
type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest,
type TripAddMemberRequest, type AssignmentReorderRequest,
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest,
type DayCreateRequest, type DayUpdateRequest,
type PlaceCreateRequest, type PlaceUpdateRequest,
type ReservationCreateRequest, type ReservationUpdateRequest,
type AccommodationCreateRequest, type AccommodationUpdateRequest,
type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
type PackingCreateItemRequest, type PackingUpdateItemRequest,
type TodoCreateItemRequest, type TodoUpdateItemRequest,
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest,
type PlaceBulkDeleteRequest,
type DayNoteCreateRequest, type DayNoteUpdateRequest,
type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest,
type PackingCategoryAssigneesRequest,
type BudgetUpdateMembersRequest, type BudgetToggleMemberPaidRequest, type BudgetReorderCategoriesRequest,
type TodoCategoryAssigneesRequest,
type CollabNoteCreateRequest, type CollabNoteUpdateRequest, type CollabPollCreateRequest,
type CollabPollVoteRequest, type CollabMessageCreateRequest, type CollabReactionRequest,
type FileUpdateRequest, type FileLinkRequest,
type CreateTagRequest, type UpdateTagRequest,
type CreateCategoryRequest, type UpdateCategoryRequest,
type PlaceImportListRequest,
} from '@trek/shared'
import { getSocketId } from './websocket'
import { isReachable, probeNow } from '../sync/connectivity'
/**
* Validate a response payload against its @trek/shared Zod schema — but only in
* dev, and never throwing. A drift between the server contract and the client's
* expected shape is surfaced as a console warning during development; in
* production (and on any mismatch) the data passes through untouched, so adding
* validation can never break a working call. This is the typed-request helper
* the FE adopts per domain as each backend module lands on @trek/shared.
*/
const API_DEV = Boolean((import.meta as { env?: { DEV?: boolean } }).env?.DEV)
export function parseInDev<S extends z.ZodTypeAny>(schema: S, data: unknown, label: string): z.infer<S> {
if (API_DEV) {
const result = schema.safeParse(data)
if (!result.success) {
console.warn(`[api] ${label}: response did not match the @trek/shared schema`, result.error.issues)
}
}
return data as z.infer<S>
}
const RATE_LIMIT_MESSAGES: Record<string, string> = {
en: 'Too many attempts. Please try again later.',
de: 'Zu viele Versuche. Bitte versuchen Sie es später erneut.',
@@ -154,12 +209,12 @@ apiClient.interceptors.response.use(
)
export const authApi = {
register: (data: { username: string; email: string; password: string; invite_token?: string }) => apiClient.post('/auth/register', data).then(r => r.data),
register: (data: RegisterRequest) => apiClient.post('/auth/register', data).then(r => r.data),
validateInvite: (token: string) => apiClient.get(`/auth/invite/${token}`).then(r => r.data),
login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data),
verifyMfaLogin: (data: { mfa_token: string; code: string }) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data),
login: (data: LoginRequest) => apiClient.post('/auth/login', data).then(r => r.data),
verifyMfaLogin: (data: MfaVerifyLoginRequest) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data),
mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).then(r => r.data),
mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }),
mfaEnable: (data: MfaEnableRequest) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }),
mfaDisable: (data: { password: string; code: string }) => apiClient.post('/auth/mfa/disable', data).then(r => r.data),
me: () => apiClient.get('/auth/me').then(r => r.data),
updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
@@ -173,14 +228,14 @@ export const authApi = {
updateAppSettings: (data: Record<string, unknown>) => apiClient.put('/auth/app-settings', data).then(r => r.data),
validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data),
travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data),
changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data),
forgotPassword: (data: { email: string }) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }),
resetPassword: (data: { token: string; new_password: string; mfa_code?: string }) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }),
changePassword: (data: ChangePasswordRequest) => apiClient.put('/auth/me/password', data).then(r => r.data),
forgotPassword: (data: ForgotPasswordRequest) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }),
resetPassword: (data: ResetPasswordRequest) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }),
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
mcpTokens: {
list: () => apiClient.get('/auth/mcp-tokens').then(r => r.data),
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name }).then(r => r.data),
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data),
delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data),
},
}
@@ -226,32 +281,32 @@ export const oauthApi = {
export const tripsApi = {
list: (params?: Record<string, unknown>) => apiClient.get('/trips', { params }).then(r => r.data),
create: (data: Record<string, unknown>) => apiClient.post('/trips', data).then(r => r.data),
create: (data: TripCreateRequest) => apiClient.post('/trips', data).then(r => r.data),
get: (id: number | string) => apiClient.get(`/trips/${id}`).then(r => r.data),
update: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
update: (id: number | string, data: TripUpdateRequest) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data),
uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data),
unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data),
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier } satisfies TripAddMemberRequest).then(r => r.data),
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
copy: (id: number | string, data?: { title?: string }) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
copy: (id: number | string, data?: TripCopyRequest) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data),
}
export const daysApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
update: (tripId: number | string, dayId: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
}
export const placesApi = {
list: (tripId: number | string, params?: Record<string, unknown>) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
create: (tripId: number | string, data: PlaceCreateRequest) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data),
update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => {
@@ -270,64 +325,64 @@ export const placesApi = {
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
},
importGoogleList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url } satisfies PlaceImportListRequest).then(r => r.data),
importNaverList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
bulkDelete: (tripId: number | string, ids: number[]) =>
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data),
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
}
export const assignmentsApi = {
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data),
create: (tripId: number | string, dayId: number | string, data: { place_id: number | string }) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data),
create: (tripId: number | string, dayId: number | string, data: AssignmentCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data),
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data),
reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds }).then(r => r.data),
reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds } satisfies AssignmentReorderRequest).then(r => r.data),
move: (tripId: number | string, assignmentId: number, newDayId: number | string, orderIndex: number | null) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data),
update: (tripId: number | string, dayId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data),
getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds }).then(r => r.data),
updateTime: (tripId: number | string, id: number, times: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds } satisfies AssignmentParticipantsRequest).then(r => r.data),
updateTime: (tripId: number | string, id: number, times: AssignmentTimeRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
}
export const packingApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items }).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
create: (tripId: number | string, data: PackingCreateItemRequest) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items } satisfies PackingImportRequest).then(r => r.data),
update: (tripId: number | string, id: number, data: PackingUpdateItemRequest) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data),
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data),
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data),
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds }).then(r => r.data),
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds } satisfies PackingBagMembersRequest).then(r => r.data),
listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data),
createBag: (tripId: number | string, data: { name: string; color?: string }) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data),
updateBag: (tripId: number | string, bagId: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data),
createBag: (tripId: number | string, data: PackingCreateBagRequest) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data),
updateBag: (tripId: number | string, bagId: number, data: PackingUpdateBagRequest) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data),
deleteBag: (tripId: number | string, bagId: number) => apiClient.delete(`/trips/${tripId}/packing/bags/${bagId}`).then(r => r.data),
}
export const todoApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data),
create: (tripId: number | string, data: TodoCreateItemRequest) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: TodoUpdateItemRequest) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/todo/${id}`).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds }).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds } satisfies TodoReorderRequest).then(r => r.data),
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo/category-assignees`).then(r => r.data),
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies TodoCategoryAssigneesRequest).then(r => r.data),
}
export const tagsApi = {
list: () => apiClient.get('/tags').then(r => r.data),
create: (data: Record<string, unknown>) => apiClient.post('/tags', data).then(r => r.data),
update: (id: number, data: Record<string, unknown>) => apiClient.put(`/tags/${id}`, data).then(r => r.data),
create: (data: CreateTagRequest) => apiClient.post('/tags', data).then(r => r.data),
update: (id: number, data: UpdateTagRequest) => apiClient.put(`/tags/${id}`, data).then(r => r.data),
delete: (id: number) => apiClient.delete(`/tags/${id}`).then(r => r.data),
}
export const categoriesApi = {
list: () => apiClient.get('/categories').then(r => r.data),
create: (data: Record<string, unknown>) => apiClient.post('/categories', data).then(r => r.data),
update: (id: number, data: Record<string, unknown>) => apiClient.put(`/categories/${id}`, data).then(r => r.data),
create: (data: CreateCategoryRequest) => apiClient.post('/categories', data).then(r => r.data),
update: (id: number, data: UpdateCategoryRequest) => apiClient.put(`/categories/${id}`, data).then(r => r.data),
delete: (id: number) => apiClient.delete(`/categories/${id}`).then(r => r.data),
}
@@ -390,7 +445,7 @@ export const addonsApi = {
export const journeyApi = {
list: () => apiClient.get('/journeys').then(r => r.data),
create: (data: { title: string; subtitle?: string; trip_ids?: number[] }) => apiClient.post('/journeys', data).then(r => r.data),
create: (data: JourneyCreateRequest) => apiClient.post('/journeys', data).then(r => r.data),
get: (id: number) => apiClient.get(`/journeys/${id}`).then(r => r.data),
update: (id: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/${id}`, data).then(r => r.data),
delete: (id: number) => apiClient.delete(`/journeys/${id}`).then(r => r.data),
@@ -399,7 +454,7 @@ export const journeyApi = {
availableTrips: () => apiClient.get('/journeys/available-trips').then(r => r.data),
// Trips (sync sources)
addTrip: (id: number, tripId: number) => apiClient.post(`/journeys/${id}/trips`, { trip_id: tripId }).then(r => r.data),
addTrip: (id: number, tripId: number) => apiClient.post(`/journeys/${id}/trips`, { trip_id: tripId } satisfies JourneyAddTripRequest).then(r => r.data),
removeTrip: (id: number, tripId: number) => apiClient.delete(`/journeys/${id}/trips/${tripId}`).then(r => r.data),
// Entries
@@ -407,7 +462,7 @@ export const journeyApi = {
createEntry: (id: number, data: Record<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
updateEntry: (entryId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data),
deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data),
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data),
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds } satisfies JourneyReorderEntriesRequest).then(r => r.data),
// Photos
uploadPhotos: (entryId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
@@ -424,7 +479,7 @@ export const journeyApi = {
onUploadProgress: opts?.onUploadProgress,
signal: opts?.signal,
}).then(r => r.data),
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data),
@@ -446,7 +501,7 @@ export const journeyApi = {
// Share
getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data),
createShareLink: (id: number, perms: { share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean }) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data),
createShareLink: (id: number, perms: JourneyShareLinkRequest) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data),
deleteShareLink: (id: number) => apiClient.delete(`/journeys/${id}/share-link`).then(r => r.data),
getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data),
}
@@ -468,15 +523,15 @@ export const airportsApi = {
export const budgetApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data),
create: (tripId: number | string, data: BudgetCreateItemRequest) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: BudgetUpdateItemRequest) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data),
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data),
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds } satisfies BudgetUpdateMembersRequest).then(r => r.data),
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid } satisfies BudgetToggleMemberPaidRequest).then(r => r.data),
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data),
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories }).then(r => r.data),
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
}
export const filesApi = {
@@ -484,13 +539,13 @@ export const filesApi = {
upload: (tripId: number | string, formData: FormData) => apiClient.post(`/trips/${tripId}/files`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
}).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: FileUpdateRequest) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data),
toggleStar: (tripId: number | string, id: number) => apiClient.patch(`/trips/${tripId}/files/${id}/star`).then(r => r.data),
restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).then(r => r.data),
permanentDelete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}/permanent`).then(r => r.data),
emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data),
addLink: (tripId: number | string, fileId: number, data: { reservation_id?: number; assignment_id?: number }) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data),
addLink: (tripId: number | string, fileId: number, data: FileLinkRequest) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data),
removeLink: (tripId: number | string, fileId: number, linkId: number) => apiClient.delete(`/trips/${tripId}/files/${fileId}/link/${linkId}`).then(r => r.data),
getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data),
}
@@ -498,15 +553,15 @@ export const filesApi = {
export const reservationsApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data),
upcoming: () => apiClient.get('/reservations/upcoming').then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
create: (tripId: number | string, data: ReservationCreateRequest) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
}
export const weatherApi = {
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')),
}
export const configApi = {
@@ -516,40 +571,46 @@ export const configApi = {
export const settingsApi = {
get: () => apiClient.get('/settings').then(r => r.data),
set: (key: string, value: unknown) => apiClient.put('/settings', { key, value }).then(r => r.data),
setBulk: (settings: Record<string, unknown>) => apiClient.post('/settings/bulk', { settings }).then(r => r.data),
set: (key: string, value: unknown) => {
const body: SettingUpsertRequest = { key, value }
return apiClient.put('/settings', body).then(r => r.data)
},
setBulk: (settings: Record<string, unknown>) => {
const body: SettingsBulkRequest = { settings }
return apiClient.post('/settings/bulk', body).then(r => r.data)
},
}
export const accommodationsApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data),
create: (tripId: number | string, data: AccommodationCreateRequest) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: AccommodationUpdateRequest) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data),
}
export const dayNotesApi = {
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/notes`).then(r => r.data),
create: (tripId: number | string, dayId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data),
update: (tripId: number | string, dayId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data),
create: (tripId: number | string, dayId: number | string, data: DayNoteCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data),
update: (tripId: number | string, dayId: number | string, id: number, data: DayNoteUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data),
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data),
}
export const collabApi = {
getNotes: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/notes`).then(r => r.data),
createNote: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data),
updateNote: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data),
createNote: (tripId: number | string, data: CollabNoteCreateRequest) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data),
updateNote: (tripId: number | string, id: number, data: CollabNoteUpdateRequest) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data),
deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => apiClient.post(`/trips/${tripId}/collab/notes/${noteId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data),
getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
createPoll: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex }).then(r => r.data),
createPoll: (tripId: number | string, data: CollabPollCreateRequest) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex } satisfies CollabPollVoteRequest).then(r => r.data),
closePoll: (tripId: number | string, id: number) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data),
deletePoll: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data),
getMessages: (tripId: number | string, before?: string) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data),
sendMessage: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
sendMessage: (tripId: number | string, data: CollabMessageCreateRequest) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
deleteMessage: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data),
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji }).then(r => r.data),
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji } satisfies CollabReactionRequest).then(r => r.data),
linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data),
}
@@ -596,10 +657,10 @@ export const notificationsApi = {
}
export const inAppNotificationsApi = {
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) =>
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
unreadCount: () =>
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }): Promise<InAppListResult> =>
apiClient.get('/notifications/in-app', { params }).then(r => parseInDev(inAppListResultSchema, r.data, 'notifications.list')),
unreadCount: (): Promise<UnreadCountResult> =>
apiClient.get('/notifications/in-app/unread-count').then(r => parseInDev(unreadCountResultSchema, r.data, 'notifications.unreadCount')),
markRead: (id: number) =>
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
markUnread: (id: number) =>
@@ -610,7 +671,7 @@ export const inAppNotificationsApi = {
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
deleteAll: () =>
apiClient.delete('/notifications/in-app/all').then(r => r.data),
respond: (id: number, response: 'positive' | 'negative') =>
respond: (id: number, response: NotificationRespondRequest['response']) =>
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
}
+38 -10
View File
@@ -771,12 +771,40 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
<div style={{ display: 'flex', gap: 20, padding: '24px 28px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }} className="max-md:!px-4">
<div style={{ flex: 1, minWidth: 0 }}>
{categoryNames.map((cat, ci) => {
const items = grouped.get(cat) || []
const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0)
const color = categoryColor(cat)
{categoryNames.map(cat => (
<BudgetCategoryTable key={cat} cat={cat} grouped={grouped} categoryColor={categoryColor}
canEdit={canEdit} editingCat={editingCat} setEditingCat={setEditingCat}
dragCat={dragCat} setDragCat={setDragCat} dragOverCat={dragOverCat} setDragOverCat={setDragOverCat}
dragItem={dragItem} setDragItem={setDragItem} dragOverItem={dragOverItem} setDragOverItem={setDragOverItem}
dragItemCat={dragItemCat} setDragItemCat={setDragItemCat}
categoryNames={categoryNames} reorderBudgetCategories={reorderBudgetCategories} reorderBudgetItems={reorderBudgetItems}
handleRenameCategory={handleRenameCategory} handleDeleteCategory={handleDeleteCategory} handleDeleteItem={handleDeleteItem}
handleUpdateField={handleUpdateField} handleAddItem={handleAddItem}
tripId={tripId} currency={currency} locale={locale} t={t} fmt={fmt}
hasMultipleMembers={hasMultipleMembers} tripMembers={tripMembers}
setBudgetItemMembers={setBudgetItemMembers} toggleBudgetMemberPaid={toggleBudgetMemberPaid}
th={th} td={td} />
))}
</div>
return (
<BudgetSummary theme={theme} currency={currency} locale={locale} grandTotal={grandTotal}
hasMultipleMembers={hasMultipleMembers} budgetItems={budgetItems} settlement={settlement}
settlementOpen={settlementOpen} setSettlementOpen={setSettlementOpen} pieSegments={pieSegments}
isDark={isDark} tripId={tripId} t={t} fmt={fmt} />
</div>
</div>
)
}
function BudgetCategoryTable({ cat, grouped, categoryColor, canEdit, editingCat, setEditingCat,
dragCat, setDragCat, dragOverCat, setDragOverCat, dragItem, setDragItem, dragOverItem, setDragOverItem,
dragItemCat, setDragItemCat, categoryNames, reorderBudgetCategories, reorderBudgetItems,
handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleUpdateField, handleAddItem,
tripId, currency, locale, t, fmt, hasMultipleMembers, tripMembers, setBudgetItemMembers, toggleBudgetMemberPaid, th, td }: any) {
const items = grouped.get(cat) || []
const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0)
const color = categoryColor(cat)
return (
<div key={cat} data-drag-cat={cat} style={{
marginBottom: 16, opacity: dragCat === cat ? 0.4 : 1,
transition: 'opacity 0.15s',
@@ -975,10 +1003,12 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
</table>
</div>
</div>
)
})}
</div>
)
}
function BudgetSummary({ theme, currency, locale, grandTotal, hasMultipleMembers, budgetItems,
settlement, settlementOpen, setSettlementOpen, pieSegments, isDark, tripId, t, fmt }: any) {
return (
<div className="w-full md:w-[320px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
<div style={{
@@ -1227,7 +1257,5 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
})()}
</div>
</div>
</div>
)
}
+99 -87
View File
@@ -353,6 +353,99 @@ interface CollabChatProps {
}
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
const S = useCollabChat(tripId, currentUser)
const { t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = S
if (loading) {
return (
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<div style={{ width: 24, height: 24, border: '2px solid var(--border-faint)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin .7s linear infinite' }} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
}
return (
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, height: '100%' }}>
<ChatMessages {...S} />
{/* Composer */}
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }} className="pb-3">
{/* Reply preview */}
{replyTo && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8,
padding: '6px 10px', borderRadius: 10, background: 'var(--bg-secondary)',
borderLeft: '3px solid #007AFF', fontSize: 12, color: 'var(--text-muted)',
}}>
<Reply size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
<strong>{replyTo.username}</strong>: {(replyTo.text || '').slice(0, 60)}
</span>
<button onClick={() => setReplyTo(null)} style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)',
display: 'flex', flexShrink: 0,
}}>
<X size={14} />
</button>
</div>
)}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
{/* Emoji button */}
{canEdit && (
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
}}>
<Smile size={20} />
</button>
)}
<textarea
ref={textareaRef}
rows={1}
disabled={!canEdit}
style={{
flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20,
padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit',
background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none',
maxHeight: 100, overflowY: 'hidden',
opacity: canEdit ? 1 : 0.5,
}}
placeholder={t('collab.chat.placeholder')}
value={text}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
/>
{/* Send */}
{canEdit && (
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
transition: 'background 0.15s',
}}>
<ArrowUp size={18} strokeWidth={2.5} />
</button>
)}
</div>
</div>
{/* Emoji picker */}
{showEmoji && <EmojiPicker onSelect={handleEmojiSelect} onClose={() => setShowEmoji(false)} anchorRef={emojiBtnRef} containerRef={containerRef} />}
{/* Reaction quick menu (right-click) */}
{reactMenu && ReactDOM.createPortal(
<ReactionMenu x={reactMenu.x} y={reactMenu.y} onReact={(emoji) => handleReact(reactMenu.msgId, emoji)} onClose={() => setReactMenu(null)} />,
document.body
)}
</div>
)
}
function useCollabChat(tripId: any, currentUser: any) {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const can = useCanDo()
@@ -519,19 +612,13 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
return emojiRegex.test(text.trim())
}
/* ── Loading ── */
if (loading) {
return (
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<div style={{ width: 24, height: 24, border: '2px solid var(--border-faint)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin .7s linear infinite' }} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
}
return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly }
}
/* ── Main ── */
function ChatMessages(props: any) {
const { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = props
return (
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, height: '100%' }}>
<>
{/* Messages */}
{messages.length === 0 ? (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--text-faint)', padding: 32 }}>
@@ -767,81 +854,6 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
</div>
)}
{/* Composer */}
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }} className="pb-3">
{/* Reply preview */}
{replyTo && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8,
padding: '6px 10px', borderRadius: 10, background: 'var(--bg-secondary)',
borderLeft: '3px solid #007AFF', fontSize: 12, color: 'var(--text-muted)',
}}>
<Reply size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
<strong>{replyTo.username}</strong>: {(replyTo.text || '').slice(0, 60)}
</span>
<button onClick={() => setReplyTo(null)} style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)',
display: 'flex', flexShrink: 0,
}}>
<X size={14} />
</button>
</div>
)}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
{/* Emoji button */}
{canEdit && (
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
}}>
<Smile size={20} />
</button>
)}
<textarea
ref={textareaRef}
rows={1}
disabled={!canEdit}
style={{
flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20,
padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit',
background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none',
maxHeight: 100, overflowY: 'hidden',
opacity: canEdit ? 1 : 0.5,
}}
placeholder={t('collab.chat.placeholder')}
value={text}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
/>
{/* Send */}
{canEdit && (
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
transition: 'background 0.15s',
}}>
<ArrowUp size={18} strokeWidth={2.5} />
</button>
)}
</div>
</div>
{/* Emoji picker */}
{showEmoji && <EmojiPicker onSelect={handleEmojiSelect} onClose={() => setShowEmoji(false)} anchorRef={emojiBtnRef} containerRef={containerRef} />}
{/* Reaction quick menu (right-click) */}
{reactMenu && ReactDOM.createPortal(
<ReactionMenu x={reactMenu.x} y={reactMenu.y} onReact={(emoji) => handleReact(reactMenu.msgId, emoji)} onClose={() => setReactMenu(null)} />,
document.body
)}
</div>
</>
)
}
+247 -298
View File
@@ -906,7 +906,12 @@ interface CollabNotesProps {
currentUser: User
}
export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
/**
* Collab notes state: load + WebSocket sync, note CRUD (with file uploads),
* category colors/renames and the view/edit/settings modal toggles. The shell
* below renders the header, category pills, the note grid and the modals.
*/
function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
const { t } = useTranslation()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
@@ -1082,316 +1087,263 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
return tB - tA
})
// ── Loading state ──
if (loading) {
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
fontFamily: FONT,
}}>
<div style={{
padding: '12px 16px',
borderBottom: '1px solid var(--border-faint)',
}}>
<h3 style={{
fontSize: 14,
fontWeight: 700,
color: 'var(--text-primary)',
margin: 0,
fontFamily: FONT,
}}>
{t('collab.notes.title')}
</h3>
</div>
<div style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<div style={{
width: 20,
height: 20,
border: '2px solid var(--border-primary)',
borderTopColor: 'var(--text-primary)',
borderRadius: '50%',
animation: 'collab-notes-spin 0.7s linear infinite',
}} />
<style>{`@keyframes collab-notes-spin { to { transform: rotate(360deg) } }`}</style>
</div>
</div>
)
return {
tripId, currentUser, t, canEdit,
notes, loading, showNewModal, setShowNewModal, editingNote, setEditingNote,
viewingNote, setViewingNote, previewFile, setPreviewFile, showSettings, setShowSettings,
activeCategory, setActiveCategory, categoryColors, getCategoryColor,
handleCreateNote, handleUpdateNote, saveCategoryColors, handleEditSubmit,
handleDeleteNoteFile, handleDeleteNote, categories, sortedNotes,
}
}
type NotesState = ReturnType<typeof useCollabNotes>
function CollabNotesLoading({ t }: NotesState) {
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
fontFamily: FONT,
}}>
{/* ── Header ── */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 16px',
flexShrink: 0,
}}>
<h3 style={{
fontSize: 12,
fontWeight: 600,
color: 'var(--text-muted)',
margin: 0,
fontFamily: FONT,
letterSpacing: 0.3,
textTransform: 'uppercase',
display: 'flex',
alignItems: 'center',
gap: 7,
}}>
<StickyNote size={14} color="var(--text-faint)" />
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border-faint)' }}>
<h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', margin: 0, fontFamily: FONT }}>
{t('collab.notes.title')}
</h3>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
{canEdit && <button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings') || 'Categories'}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', transition: 'color 0.12s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Settings size={14} />
</button>}
{canEdit && <button onClick={() => setShowNewModal(true)}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
<Plus size={12} />
{t('collab.notes.new')}
</button>}
</div>
</div>
{/* ── Category filter pills ── */}
{categories.length > 0 && (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{
display: 'flex',
gap: 4,
padding: '8px 12px 0',
overflowX: 'auto',
flexShrink: 0,
width: 20, height: 20, border: '2px solid var(--border-primary)',
borderTopColor: 'var(--text-primary)', borderRadius: '50%',
animation: 'collab-notes-spin 0.7s linear infinite',
}} />
<style>{`@keyframes collab-notes-spin { to { transform: rotate(360deg) } }`}</style>
</div>
</div>
)
}
function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: NotesState) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
<h3 style={{
fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', margin: 0, fontFamily: FONT,
letterSpacing: 0.3, textTransform: 'uppercase', display: 'flex', alignItems: 'center', gap: 7,
}}>
<StickyNote size={14} color="var(--text-faint)" />
{t('collab.notes.title')}
</h3>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
{canEdit && <button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings') || 'Categories'}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', transition: 'color 0.12s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Settings size={14} />
</button>}
{canEdit && <button onClick={() => setShowNewModal(true)}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
<Plus size={12} />
{t('collab.notes.new')}
</button>}
</div>
</div>
)
}
function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t }: NotesState) {
return (
<div style={{ display: 'flex', gap: 4, padding: '8px 12px 0', overflowX: 'auto', flexShrink: 0 }}>
<button
onClick={() => setActiveCategory(null)}
style={{
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 10, fontWeight: 600, fontFamily: FONT,
border: activeCategory === null ? '1px solid var(--accent)' : '1px solid var(--border-faint)',
background: activeCategory === null ? 'var(--accent)' : 'transparent',
color: activeCategory === null ? 'var(--accent-text)' : 'var(--text-secondary)',
cursor: 'pointer', whiteSpace: 'nowrap', textTransform: 'uppercase', letterSpacing: '0.03em',
}}
>
{t('collab.notes.all')}
</button>
{categories.map(cat => (
<button
key={cat}
onClick={() => setActiveCategory(prev => prev === cat ? null : cat)}
style={{
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 10, fontWeight: 600, fontFamily: FONT,
border: activeCategory === cat ? '1px solid var(--accent)' : '1px solid var(--border-faint)',
background: activeCategory === cat ? 'var(--accent)' : 'transparent',
color: activeCategory === cat ? 'var(--accent-text)' : 'var(--text-secondary)',
cursor: 'pointer', whiteSpace: 'nowrap', textTransform: 'uppercase', letterSpacing: '0.03em',
}}
>
{cat}
</button>
))}
</div>
)
}
function CollabNotesGrid(S: NotesState) {
const {
sortedNotes, currentUser, canEdit, handleUpdateNote, handleDeleteNote,
setEditingNote, setViewingNote, setPreviewFile, getCategoryColor, tripId, t,
} = S
return (
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
{sortedNotes.length === 0 ? (
/* ── Empty state ── */
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
padding: '48px 20px', textAlign: 'center', height: '100%',
}}>
<button
onClick={() => setActiveCategory(null)}
style={{
flexShrink: 0,
borderRadius: 99,
padding: '3px 10px',
fontSize: 10,
fontWeight: 600,
fontFamily: FONT,
border: activeCategory === null
? '1px solid var(--accent)'
: '1px solid var(--border-faint)',
background: activeCategory === null
? 'var(--accent)'
: 'transparent',
color: activeCategory === null
? 'var(--accent-text)'
: 'var(--text-secondary)',
cursor: 'pointer',
whiteSpace: 'nowrap',
textTransform: 'uppercase',
letterSpacing: '0.03em',
}}
>
{t('collab.notes.all')}
</button>
{categories.map(cat => (
<button
key={cat}
onClick={() => setActiveCategory(prev => prev === cat ? null : cat)}
style={{
flexShrink: 0,
borderRadius: 99,
padding: '3px 10px',
fontSize: 10,
fontWeight: 600,
fontFamily: FONT,
border: activeCategory === cat
? '1px solid var(--accent)'
: '1px solid var(--border-faint)',
background: activeCategory === cat
? 'var(--accent)'
: 'transparent',
color: activeCategory === cat
? 'var(--accent-text)'
: 'var(--text-secondary)',
cursor: 'pointer',
whiteSpace: 'nowrap',
textTransform: 'uppercase',
letterSpacing: '0.03em',
}}
>
{cat}
</button>
<Pencil size={36} color="var(--text-faint)" style={{ marginBottom: 12 }} />
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4, fontFamily: FONT }}>
{t('collab.notes.empty')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)', fontFamily: FONT }}>
{t('collab.notes.emptyDesc') || 'Create a note to get started'}
</div>
</div>
) : (
/* ── Notes grid — 2 columns ── */
<div style={{
display: 'grid',
gridTemplateColumns: window.innerWidth < 768 ? '1fr' : 'repeat(2, 1fr)',
gap: 8,
}}>
{sortedNotes.map(note => (
<NoteCard
key={note.id}
note={note}
currentUser={currentUser}
canEdit={canEdit}
onUpdate={handleUpdateNote}
onDelete={handleDeleteNote}
onEdit={setEditingNote}
onView={setViewingNote}
onPreviewFile={setPreviewFile}
getCategoryColor={getCategoryColor}
tripId={tripId}
t={t}
/>
))}
</div>
)}
</div>
)
}
{/* ── Scrollable content ── */}
<div style={{
flex: 1,
overflowY: 'auto',
padding: 12,
}}>
{sortedNotes.length === 0 ? (
/* ── Empty state ── */
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '48px 20px',
textAlign: 'center',
height: '100%',
}}>
<Pencil size={36} color="var(--text-faint)" style={{ marginBottom: 12 }} />
<div style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--text-secondary)',
marginBottom: 4,
fontFamily: FONT,
}}>
{t('collab.notes.empty')}
</div>
<div style={{
fontSize: 12,
color: 'var(--text-faint)',
fontFamily: FONT,
}}>
{t('collab.notes.emptyDesc') || 'Create a note to get started'}
</div>
function ViewNoteModal(S: NotesState) {
const { viewingNote, setViewingNote, canEdit, setEditingNote, getCategoryColor, t, setPreviewFile } = S
if (!viewingNote) return null
return ReactDOM.createPortal(
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 10000, padding: 16,
}}
onClick={() => setViewingNote(null)}
>
<div
style={{
background: 'var(--bg-card)', borderRadius: 16,
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
width: 'min(700px, calc(100vw - 32px))', maxHeight: '80vh',
overflow: 'hidden', display: 'flex', flexDirection: 'column',
}}
onClick={e => e.stopPropagation()}
>
<div style={{
padding: '16px 20px 12px', borderBottom: '1px solid var(--border-primary)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 17, fontWeight: 600, color: 'var(--text-primary)' }}>{viewingNote.title}</div>
{viewingNote.category && (
<span style={{
display: 'inline-block', marginTop: 4, fontSize: 10, fontWeight: 600,
color: getCategoryColor(viewingNote.category),
background: `${getCategoryColor(viewingNote.category)}18`,
padding: '2px 8px', borderRadius: 6,
}}>{viewingNote.category}</span>
)}
</div>
) : (
/* ── Notes grid — 2 columns ── */
<div style={{
display: 'grid',
gridTemplateColumns: window.innerWidth < 768 ? '1fr' : 'repeat(2, 1fr)',
gap: 8,
}}>
{sortedNotes.map(note => (
<NoteCard
key={note.id}
note={note}
currentUser={currentUser}
canEdit={canEdit}
onUpdate={handleUpdateNote}
onDelete={handleDeleteNote}
onEdit={setEditingNote}
onView={setViewingNote}
onPreviewFile={setPreviewFile}
getCategoryColor={getCategoryColor}
tripId={tripId}
t={t}
/>
))}
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
{canEdit && <button onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={16} />
</button>}
<button onClick={() => setViewingNote(null)}
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<X size={18} />
</button>
</div>
)}
</div>
{/* ── New Note Modal ── */}
{/* View note modal */}
{viewingNote && ReactDOM.createPortal(
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 10000, padding: 16,
}}
onClick={() => setViewingNote(null)}
>
<div
style={{
background: 'var(--bg-card)', borderRadius: 16,
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
width: 'min(700px, calc(100vw - 32px))', maxHeight: '80vh',
overflow: 'hidden', display: 'flex', flexDirection: 'column',
}}
onClick={e => e.stopPropagation()}
>
<div style={{
padding: '16px 20px 12px', borderBottom: '1px solid var(--border-primary)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 17, fontWeight: 600, color: 'var(--text-primary)' }}>{viewingNote.title}</div>
{viewingNote.category && (
<span style={{
display: 'inline-block', marginTop: 4, fontSize: 10, fontWeight: 600,
color: getCategoryColor(viewingNote.category),
background: `${getCategoryColor(viewingNote.category)}18`,
padding: '2px 8px', borderRadius: 6,
}}>{viewingNote.category}</span>
)}
</div>
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
{canEdit && <button onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={16} />
</button>}
<button onClick={() => setViewingNote(null)}
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<X size={18} />
</button>
</div>
</div>
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7 }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{viewingNote.content || ''}</Markdown>
{(viewingNote.attachments || []).length > 0 && (
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{(viewingNote.attachments || []).map(a => {
const isImage = a.mime_type?.startsWith('image/')
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
return (
<div key={a.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, maxWidth: 72 }}>
{isImage ? (
<AuthedImg src={a.url} alt={a.original_name}
style={{ width: 64, height: 64, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', transition: 'transform 0.12s, box-shadow 0.12s' }}
onClick={() => setPreviewFile(a)}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }} />
) : (
<div title={a.original_name} onClick={() => setPreviewFile(a)}
style={{
width: 64, height: 64, borderRadius: 8, cursor: 'pointer',
background: a.mime_type === 'application/pdf' ? '#ef44441a' : 'var(--bg-secondary)',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1,
transition: 'transform 0.12s, box-shadow 0.12s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
<span style={{ fontSize: 10, fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
</div>
)}
<span style={{ fontSize: 9, color: 'var(--text-faint)', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', width: '100%' }}>{a.original_name}</span>
</div>
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7 }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{viewingNote.content || ''}</Markdown>
{(viewingNote.attachments || []).length > 0 && (
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{(viewingNote.attachments || []).map(a => {
const isImage = a.mime_type?.startsWith('image/')
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
return (
<div key={a.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, maxWidth: 72 }}>
{isImage ? (
<AuthedImg src={a.url} alt={a.original_name}
style={{ width: 64, height: 64, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', transition: 'transform 0.12s, box-shadow 0.12s' }}
onClick={() => setPreviewFile(a)}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }} />
) : (
<div title={a.original_name} onClick={() => setPreviewFile(a)}
style={{
width: 64, height: 64, borderRadius: 8, cursor: 'pointer',
background: a.mime_type === 'application/pdf' ? '#ef44441a' : 'var(--bg-secondary)',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1,
transition: 'transform 0.12s, box-shadow 0.12s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
<span style={{ fontSize: 10, fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
</div>
)
})}
</div>
</div>
)}
)}
<span style={{ fontSize: 9, color: 'var(--text-faint)', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', width: '100%' }}>{a.original_name}</span>
</div>
)
})}
</div>
</div>
</div>
</div>,
document.body
)}
)}
</div>
</div>
</div>,
document.body
)
}
export default function CollabNotes(props: CollabNotesProps) {
const S = useCollabNotes(props)
const {
loading, tripId, t, categories, categoryColors, getCategoryColor, notes,
viewingNote, showNewModal, editingNote, previewFile, showSettings,
setShowNewModal, setEditingNote, setPreviewFile, setShowSettings,
handleCreateNote, handleEditSubmit, handleDeleteNoteFile, saveCategoryColors, handleUpdateNote,
} = S
if (loading) return <CollabNotesLoading {...S} />
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
<CollabNotesHeader {...S} />
{categories.length > 0 && <CollabCategoryPills {...S} />}
<CollabNotesGrid {...S} />
{viewingNote && <ViewNoteModal {...S} />}
{showNewModal && (
<NoteFormModal
@@ -1406,7 +1358,6 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
/>
)}
{/* ── Edit Note Modal ── */}
{editingNote && (
<NoteFormModal
note={editingNote}
@@ -1421,10 +1372,8 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
/>
)}
{/* ── File Preview ── */}
<FilePreviewPortal file={previewFile} onClose={() => setPreviewFile(null)} />
{/* ── Category Settings Modal ── */}
{showSettings && (
<CategorySettingsModal
onClose={() => setShowSettings(false)}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
import React from 'react'
import Navbar from './Navbar'
interface PageShellProps {
children: React.ReactNode
/** Tailwind classes for the full-height root (e.g. "bg-zinc-50 dark:bg-zinc-950"). */
className?: string
/** Inline `background` for the root, for pages that theme via CSS vars (e.g. "var(--bg-secondary)"). */
background?: string
/** Props forwarded to the shared Navbar (trip title, back button, …). */
navbar?: React.ComponentProps<typeof Navbar>
/** paddingTop offset that clears the fixed Navbar. Defaults to the global --nav-h. */
navOffset?: string
/** Classes/style for the nav-offset content wrapper. */
contentClassName?: string
contentStyle?: React.CSSProperties
}
/**
* The standard authenticated page chrome: a full-height themed root, the shared
* fixed Navbar, and a content wrapper offset by the navbar height. Both the web
* app and the PWA shell render through this so the offset/background handling
* lives in one place instead of being copy-pasted into every page.
*/
export default function PageShell({
children,
className,
background,
navbar,
navOffset = 'var(--nav-h)',
contentClassName,
contentStyle,
}: PageShellProps): React.ReactElement {
return (
<div className={`min-h-screen${className ? ' ' + className : ''}`} style={background ? { background } : undefined}>
<Navbar {...navbar} />
<div className={contentClassName} style={{ paddingTop: navOffset, ...contentStyle }}>
{children}
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
+509 -434
View File
@@ -759,7 +759,15 @@ interface PackingListPanelProps {
inlineHeader?: boolean
}
export default function PackingListPanel({ tripId, items, openImportSignal = 0, clearCheckedSignal = 0, saveTemplateSignal = 0, inlineHeader = true }: PackingListPanelProps) {
/**
* Packing list state: trip members + per-category assignees, category grouping
* and progress, item/category CRUD, bag tracking (weights + members) and the
* template apply/save + bulk CSV import flows (driven by signal props). The
* sections below render header, filters, the grouped list, the bag sidebar/
* modal and the import dialog.
*/
function usePackingList({ tripId, items, openImportSignal = 0, clearCheckedSignal = 0, saveTemplateSignal = 0, inlineHeader = true }: PackingListPanelProps) {
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
const [addingCategory, setAddingCategory] = useState(false)
const [newCatName, setNewCatName] = useState('')
@@ -833,7 +841,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
let catName = newCatName.trim()
// Allow duplicate display names — append invisible zero-width spaces to make unique internally
while (allCategories.includes(catName)) {
catName += '\u200B'
catName += ''
}
try {
await addPackingItem(tripId, { name: '...', category: catName })
@@ -1047,401 +1055,530 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
return {
tripId, items, inlineHeader, t, canEdit, font,
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleClearChecked,
bagTrackingEnabled, bags, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
availableTemplates, showTemplateDropdown, setShowTemplateDropdown, applyingTemplate,
showSaveTemplate, setShowSaveTemplate, saveTemplateName, setSaveTemplateName,
showImportModal, setShowImportModal, importText, setImportText,
csvInputRef, templateDropdownRef, handleApplyTemplate, handleSaveAsTemplate, parseImportLines, handleBulkImport, handleCsvFile,
}
}
{/* ── Header ── */}
<div style={{ padding: inlineHeader ? '20px 24px 16px' : '0 0 16px', flexShrink: 0, borderBottom: inlineHeader ? '1px solid rgba(0,0,0,0.06)' : undefined }}>
<div style={{ display: 'flex', alignItems: inlineHeader ? 'flex-start' : 'center', justifyContent: 'space-between', gap: 14 }}>
{inlineHeader ? (
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.title')}</h2>
{items.length > 0 && (
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
{t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
</p>
type PackingState = ReturnType<typeof usePackingList>
function PackingHeader(S: PackingState) {
const {
inlineHeader, t, items, abgehakt, fortschritt, canEdit,
showSaveTemplate, saveTemplateName, setSaveTemplateName, handleSaveAsTemplate, setShowSaveTemplate,
setShowImportModal, handleClearChecked, availableTemplates, templateDropdownRef,
showTemplateDropdown, setShowTemplateDropdown, applyingTemplate, handleApplyTemplate,
bagTrackingEnabled, showBagModal, setShowBagModal,
addingCategory, newCatName, setNewCatName, handleAddNewCategory, setAddingCategory,
} = S
return (
<div style={{ padding: inlineHeader ? '20px 24px 16px' : '0 0 16px', flexShrink: 0, borderBottom: inlineHeader ? '1px solid rgba(0,0,0,0.06)' : undefined }}>
<div style={{ display: 'flex', alignItems: inlineHeader ? 'flex-start' : 'center', justifyContent: 'space-between', gap: 14 }}>
{inlineHeader ? (
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.title')}</h2>
{items.length > 0 && (
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
{t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
</p>
)}
</div>
) : <span />}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
{canEdit && items.length > 0 && showSaveTemplate && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<input
type="text" autoFocus
value={saveTemplateName}
onChange={e => setSaveTemplateName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSaveAsTemplate(); if (e.key === 'Escape') { setShowSaveTemplate(false); setSaveTemplateName('') } }}
placeholder={t('packing.templateName')}
style={{ fontSize: 12, padding: '5px 10px', borderRadius: 99, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', width: 140, background: 'var(--bg-card)', color: 'var(--text-primary)' }}
/>
<button onClick={handleSaveAsTemplate} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#10b981' }}><Check size={14} /></button>
<button onClick={() => { setShowSaveTemplate(false); setSaveTemplateName('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}><X size={14} /></button>
</div>
)}
{inlineHeader && canEdit && (
<button onClick={() => setShowImportModal(true)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-muted)',
}}>
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
</button>
)}
{inlineHeader && canEdit && abgehakt > 0 && (
<button onClick={handleClearChecked} style={{
fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontFamily: 'inherit',
}}>
<span className="hidden sm:inline">{t('packing.clearChecked', { count: abgehakt })}</span>
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
</button>
)}
{inlineHeader && canEdit && availableTemplates.length > 0 && (
<div ref={templateDropdownRef} style={{ position: 'relative' }}>
<button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
background: showTemplateDropdown ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: showTemplateDropdown ? 'var(--text-primary)' : 'var(--border-primary)',
color: showTemplateDropdown ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>
<Package size={12} /> <span className="hidden sm:inline">{t('packing.applyTemplate')}</span><span className="sm:hidden">{t('packing.template')}</span>
</button>
{showTemplateDropdown && (
<div style={{
position: 'absolute', right: 0, top: '100%', marginTop: 6, zIndex: 50,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 200,
}}>
{availableTemplates.map(tmpl => (
<button key={tmpl.id} onClick={() => handleApplyTemplate(tmpl.id)}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '8px 12px', borderRadius: 8, border: 'none', cursor: 'pointer',
background: 'transparent', fontFamily: 'inherit', fontSize: 12, color: 'var(--text-primary)',
transition: 'background 0.1s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<Package size={13} style={{ color: 'var(--text-faint)' }} />
<div style={{ flex: 1, textAlign: 'left' }}>
<div style={{ fontWeight: 600 }}>{tmpl.name}</div>
<div style={{ fontSize: 10, color: 'var(--text-faint)' }}>{tmpl.item_count} {t('admin.packingTemplates.items')}</div>
</div>
</button>
))}
</div>
)}
</div>
) : <span />}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
{canEdit && items.length > 0 && showSaveTemplate && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<input
type="text" autoFocus
value={saveTemplateName}
onChange={e => setSaveTemplateName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSaveAsTemplate(); if (e.key === 'Escape') { setShowSaveTemplate(false); setSaveTemplateName('') } }}
placeholder={t('packing.templateName')}
style={{ fontSize: 12, padding: '5px 10px', borderRadius: 99, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', width: 140, background: 'var(--bg-card)', color: 'var(--text-primary)' }}
/>
<button onClick={handleSaveAsTemplate} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#10b981' }}><Check size={14} /></button>
<button onClick={() => { setShowSaveTemplate(false); setSaveTemplateName('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}><X size={14} /></button>
)}
{inlineHeader && canEdit && items.length > 0 && !showSaveTemplate && (
<button onClick={() => setShowSaveTemplate(true)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
background: 'var(--bg-card)', color: 'var(--text-muted)',
}}>
<FolderPlus size={12} /> <span className="hidden sm:inline">{t('packing.saveAsTemplate')}</span>
</button>
)}
{bagTrackingEnabled && (
<button onClick={() => setShowBagModal(true)} className="xl:!hidden"
style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
background: showBagModal ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: showBagModal ? 'var(--text-primary)' : 'var(--border-primary)',
color: showBagModal ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>
<Luggage size={12} /> {t('packing.bags')}
</button>
)}
</div>
</div>
{items.length > 0 && (
<div className="hidden sm:block" style={{ marginTop: 14, marginBottom: 14 }}>
<div className="flex items-center" style={{ gap: 14 }}>
{fortschritt === 100 ? (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
fontSize: 16, fontWeight: 700, color: '#10b981',
letterSpacing: '-0.01em', flexShrink: 0,
}}>
<CheckCheck size={18} strokeWidth={2.5} />
<span>{t('packing.allPacked')}</span>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'baseline' }}>
<span style={{
fontSize: 22, fontWeight: 700, color: 'var(--text-primary)',
fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.02em',
lineHeight: 1,
}}>{abgehakt}</span>
<span style={{
fontSize: 14, fontWeight: 500, color: 'var(--text-faint)',
fontVariantNumeric: 'tabular-nums', lineHeight: 1, marginLeft: 1,
}}>/{items.length}</span>
</div>
<span style={{
fontSize: 11, fontWeight: 600, padding: '2px 7px',
borderRadius: 99, background: 'var(--bg-tertiary)',
color: 'var(--text-muted)',
fontVariantNumeric: 'tabular-nums',
lineHeight: 1.4,
}}>{fortschritt}%</span>
</div>
)}
{inlineHeader && canEdit && (
<button onClick={() => setShowImportModal(true)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-muted)',
<div style={{
flex: 1,
height: 8,
background: 'var(--bg-tertiary)',
borderRadius: 99,
overflow: 'hidden',
position: 'relative',
width: '100%',
}}>
<div style={{
height: '100%',
borderRadius: 99,
transition: 'width 600ms cubic-bezier(0.23, 1, 0.32, 1), background 400ms ease, box-shadow 400ms ease',
background: fortschritt === 100
? 'linear-gradient(90deg, #10b981 0%, #34d399 100%)'
: 'var(--accent)',
width: `${fortschritt}%`,
boxShadow: fortschritt === 100 ? '0 0 14px rgba(16,185,129,0.45)' : 'none',
position: 'relative',
}}>
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
</button>
)}
{inlineHeader && canEdit && abgehakt > 0 && (
<button onClick={handleClearChecked} style={{
fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontFamily: 'inherit',
}}>
<span className="hidden sm:inline">{t('packing.clearChecked', { count: abgehakt })}</span>
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
</button>
)}
{inlineHeader && canEdit && availableTemplates.length > 0 && (
<div ref={templateDropdownRef} style={{ position: 'relative' }}>
<button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
background: showTemplateDropdown ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: showTemplateDropdown ? 'var(--text-primary)' : 'var(--border-primary)',
color: showTemplateDropdown ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>
<Package size={12} /> <span className="hidden sm:inline">{t('packing.applyTemplate')}</span><span className="sm:hidden">{t('packing.template')}</span>
</button>
{showTemplateDropdown && (
<div style={{
position: 'absolute', right: 0, top: '100%', marginTop: 6, zIndex: 50,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 200,
}}>
{availableTemplates.map(tmpl => (
<button key={tmpl.id} onClick={() => handleApplyTemplate(tmpl.id)}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '8px 12px', borderRadius: 8, border: 'none', cursor: 'pointer',
background: 'transparent', fontFamily: 'inherit', fontSize: 12, color: 'var(--text-primary)',
transition: 'background 0.1s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<Package size={13} style={{ color: 'var(--text-faint)' }} />
<div style={{ flex: 1, textAlign: 'left' }}>
<div style={{ fontWeight: 600 }}>{tmpl.name}</div>
<div style={{ fontSize: 10, color: 'var(--text-faint)' }}>{tmpl.item_count} {t('admin.packingTemplates.items')}</div>
</div>
</button>
))}
</div>
)}
<div style={{
position: 'absolute', inset: 0,
background: 'linear-gradient(180deg, rgba(255,255,255,0.22) 0%, rgba(255,255,255,0) 55%)',
borderRadius: 99,
pointerEvents: 'none',
}} />
</div>
)}
{inlineHeader && canEdit && items.length > 0 && !showSaveTemplate && (
<button onClick={() => setShowSaveTemplate(true)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
background: 'var(--bg-card)', color: 'var(--text-muted)',
}}>
<FolderPlus size={12} /> <span className="hidden sm:inline">{t('packing.saveAsTemplate')}</span>
</button>
)}
{bagTrackingEnabled && (
<button onClick={() => setShowBagModal(true)} className="xl:!hidden"
style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
background: showBagModal ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: showBagModal ? 'var(--text-primary)' : 'var(--border-primary)',
color: showBagModal ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>
<Luggage size={12} /> {t('packing.bags')}
</button>
)}
</div>
</div>
</div>
)}
{canEdit && (addingCategory ? (
<div style={{ display: 'flex', gap: 6 }}>
<input
autoFocus
type="text" value={newCatName} onChange={e => setNewCatName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddNewCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
placeholder={t('packing.newCategoryPlaceholder')}
style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13.5, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)' }}
/>
<button onClick={handleAddNewCategory} disabled={!newCatName.trim()}
style={{ padding: '8px 12px', borderRadius: 10, border: 'none', background: newCatName.trim() ? 'var(--text-primary)' : 'var(--border-primary)', color: 'var(--bg-primary)', cursor: newCatName.trim() ? 'pointer' : 'default', display: 'flex', alignItems: 'center' }}>
<Check size={16} />
</button>
<button onClick={() => { setAddingCategory(false); setNewCatName('') }}
style={{ padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}>
<X size={16} />
</button>
</div>
) : (
<button onClick={() => setAddingCategory(true)}
style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '9px 14px', borderRadius: 10, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-faint)', fontFamily: 'inherit', transition: 'all 0.15s' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-muted)'; e.currentTarget.style.color = 'var(--text-secondary)' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
<FolderPlus size={14} /> {t('packing.addCategory')}
</button>
))}
</div>
)
}
function PackingFilterTabs({ items, filter, setFilter, t }: PackingState) {
if (items.length === 0) return null
return (
<div style={{ display: 'flex', gap: 4, padding: '10px 0 0', flexShrink: 0 }}>
{[['alle', t('packing.filterAll')], ['offen', t('packing.filterOpen')], ['erledigt', t('packing.filterDone')]].map(([id, label]) => (
<button key={id} onClick={() => setFilter(id)} style={{
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer',
fontSize: 12, fontFamily: 'inherit', fontWeight: filter === id ? 600 : 400,
background: filter === id ? 'var(--text-primary)' : 'transparent',
color: filter === id ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>{label}</button>
))}
</div>
)
}
function PackingList(S: PackingState) {
const {
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory,
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
bagTrackingEnabled, bags, handleCreateBagByName, canEdit,
} = S
return (
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}>
{items.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
<Luggage size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 10px' }} />
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('packing.emptyTitle')}</p>
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('packing.emptyHint')}</p>
</div>
) : Object.keys(gruppiert).length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--text-faint)' }}>
<p style={{ fontSize: 13, margin: 0 }}>{t('packing.emptyFiltered')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{Object.entries(gruppiert).map(([kat, katItems]) => (
<KategorieGruppe
key={kat}
kategorie={kat}
items={katItems}
tripId={tripId}
allCategories={allCategories}
onRename={handleRenameCategory}
onDeleteAll={handleDeleteCategory}
onAddItem={handleAddItemToCategory}
assignees={categoryAssignees[kat] || []}
tripMembers={tripMembers}
onSetAssignees={handleSetAssignees}
bagTrackingEnabled={bagTrackingEnabled}
bags={bags}
onCreateBag={handleCreateBagByName}
canEdit={canEdit}
/>
))}
</div>
)}
</div>
)
}
function BagSidebar(S: PackingState) {
const {
t, bags, items, tripId, tripMembers, canEdit, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
showAddBag, setShowAddBag, newBagName, setNewBagName, handleCreateBag,
} = S
return (
<div className="hidden xl:block" style={{ width: 260, borderLeft: '1px solid var(--border-secondary)', overflowY: 'auto', padding: 16, flexShrink: 0 }}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-faint)', marginBottom: 12 }}>
{t('packing.bags')}
</div>
{bags.map(bag => {
const bagItems = items.filter(i => i.bag_id === bag.id)
const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0)
const maxWeight = bag.weight_limit_grams || Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1)
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
return (
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact />
)
})}
{/* Unassigned */}
{(() => {
const unassigned = items.filter(i => !i.bag_id)
const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0)
if (unassigned.length === 0) return null
return (
<div style={{ marginBottom: 14, opacity: 0.6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', border: '2px dashed var(--border-primary)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12, fontWeight: 600, color: 'var(--text-faint)' }}>{t('packing.noBag')}</span>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>
{unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`}
</span>
</div>
<div style={{ fontSize: 10, color: 'var(--text-faint)' }}>{unassigned.length} {t('admin.packingTemplates.items')}</div>
</div>
)
})()}
{/* Total */}
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 10, marginTop: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, fontWeight: 700, color: 'var(--text-primary)' }}>
<span>{t('packing.totalWeight')}</span>
<span>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
</div>
</div>
{/* Add bag */}
{canEdit && (showAddBag ? (
<div style={{ display: 'flex', gap: 4, marginTop: 12 }}>
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
placeholder={t('packing.bagName')}
style={{ flex: 1, padding: '5px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 11, fontFamily: 'inherit', outline: 'none' }} />
<button onClick={handleCreateBag} style={{ padding: '4px 8px', borderRadius: 8, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
<Plus size={12} />
</button>
</div>
) : (
<button onClick={() => setShowAddBag(true)}
style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 12, padding: '5px 8px', borderRadius: 8, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%' }}>
<Plus size={11} /> {t('packing.addBag')}
</button>
))}
</div>
)
}
function BagModal(S: PackingState) {
const {
setShowBagModal, t, bags, items, tripId, tripMembers, canEdit, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
showAddBag, setShowAddBag, newBagName, setNewBagName, handleCreateBag,
} = S
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflowY: 'auto' }}
onClick={() => setShowBagModal(false)}>
<div style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 360, maxHeight: 'calc(100vh - 80px)', overflow: 'auto', padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.15)', flexShrink: 0 }}
onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.bags')}</h3>
<button onClick={() => setShowBagModal(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><X size={18} /></button>
</div>
{bags.map(bag => {
const bagItems = items.filter(i => i.bag_id === bag.id)
const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0)
const maxWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1)
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
return (
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} />
)
})}
{/* Unassigned */}
{(() => {
const unassigned = items.filter(i => !i.bag_id)
const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0)
if (unassigned.length === 0) return null
return (
<div style={{ marginBottom: 16, opacity: 0.6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span style={{ width: 12, height: 12, borderRadius: '50%', border: '2px dashed var(--border-primary)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 14, fontWeight: 600, color: 'var(--text-faint)' }}>{t('packing.noBag')}</span>
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
{unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`}
</span>
</div>
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{unassigned.length} {t('admin.packingTemplates.items')}</div>
</div>
)
})()}
{/* Total */}
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 12, marginTop: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>
<span>{t('packing.totalWeight')}</span>
<span>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
</div>
</div>
{items.length > 0 && (
<div className="hidden sm:block" style={{ marginTop: 14, marginBottom: 14 }}>
<div className="flex items-center" style={{ gap: 14 }}>
{fortschritt === 100 ? (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
fontSize: 16, fontWeight: 700, color: '#10b981',
letterSpacing: '-0.01em', flexShrink: 0,
}}>
<CheckCheck size={18} strokeWidth={2.5} />
<span>{t('packing.allPacked')}</span>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'baseline' }}>
<span style={{
fontSize: 22, fontWeight: 700, color: 'var(--text-primary)',
fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.02em',
lineHeight: 1,
}}>{abgehakt}</span>
<span style={{
fontSize: 14, fontWeight: 500, color: 'var(--text-faint)',
fontVariantNumeric: 'tabular-nums', lineHeight: 1, marginLeft: 1,
}}>/{items.length}</span>
</div>
<span style={{
fontSize: 11, fontWeight: 600, padding: '2px 7px',
borderRadius: 99, background: 'var(--bg-tertiary)',
color: 'var(--text-muted)',
fontVariantNumeric: 'tabular-nums',
lineHeight: 1.4,
}}>{fortschritt}%</span>
</div>
)}
<div style={{
flex: 1,
height: 8,
background: 'var(--bg-tertiary)',
borderRadius: 99,
overflow: 'hidden',
position: 'relative',
width: '100%',
}}>
<div style={{
height: '100%',
borderRadius: 99,
transition: 'width 600ms cubic-bezier(0.23, 1, 0.32, 1), background 400ms ease, box-shadow 400ms ease',
background: fortschritt === 100
? 'linear-gradient(90deg, #10b981 0%, #34d399 100%)'
: 'var(--accent)',
width: `${fortschritt}%`,
boxShadow: fortschritt === 100 ? '0 0 14px rgba(16,185,129,0.45)' : 'none',
position: 'relative',
}}>
<div style={{
position: 'absolute', inset: 0,
background: 'linear-gradient(180deg, rgba(255,255,255,0.22) 0%, rgba(255,255,255,0) 55%)',
borderRadius: 99,
pointerEvents: 'none',
}} />
</div>
</div>
</div>
</div>
)}
{canEdit && (addingCategory ? (
<div style={{ display: 'flex', gap: 6 }}>
<input
autoFocus
type="text" value={newCatName} onChange={e => setNewCatName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddNewCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
placeholder={t('packing.newCategoryPlaceholder')}
style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13.5, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)' }}
/>
<button onClick={handleAddNewCategory} disabled={!newCatName.trim()}
style={{ padding: '8px 12px', borderRadius: 10, border: 'none', background: newCatName.trim() ? 'var(--text-primary)' : 'var(--border-primary)', color: 'var(--bg-primary)', cursor: newCatName.trim() ? 'pointer' : 'default', display: 'flex', alignItems: 'center' }}>
<Check size={16} />
</button>
<button onClick={() => { setAddingCategory(false); setNewCatName('') }}
style={{ padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}>
<X size={16} />
{/* Add bag */}
{canEdit && (showAddBag ? (
<div style={{ display: 'flex', gap: 6, marginTop: 14 }}>
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
placeholder={t('packing.bagName')}
style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none' }} />
<button onClick={handleCreateBag} disabled={!newBagName.trim()}
style={{ padding: '8px 12px', borderRadius: 10, border: 'none', background: newBagName.trim() ? 'var(--text-primary)' : 'var(--border-primary)', color: 'var(--bg-primary)', cursor: newBagName.trim() ? 'pointer' : 'default', display: 'flex', alignItems: 'center' }}>
<Plus size={14} />
</button>
</div>
) : (
<button onClick={() => setAddingCategory(true)}
style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '9px 14px', borderRadius: 10, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-faint)', fontFamily: 'inherit', transition: 'all 0.15s' }}
<button onClick={() => setShowAddBag(true)}
style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 14, padding: '9px 14px', borderRadius: 10, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%', transition: 'all 0.15s' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-muted)'; e.currentTarget.style.color = 'var(--text-secondary)' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
<FolderPlus size={14} /> {t('packing.addCategory')}
<Plus size={14} /> {t('packing.addBag')}
</button>
))}
</div>
</div>
)
}
function BulkImportModal(S: PackingState) {
const { setShowImportModal, t, importText, setImportText, csvInputRef, handleCsvFile, handleBulkImport, parseImportLines } = S
return ReactDOM.createPortal(
<div style={{
position: 'fixed', inset: 0, zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
}} onClick={() => setShowImportModal(false)}>
<div style={{
width: 420, maxHeight: '80vh', background: 'var(--bg-card)', borderRadius: 16,
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
display: 'flex', flexDirection: 'column', gap: 14,
}} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{t('packing.importTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)', lineHeight: 1.5 }}>{t('packing.importHint')}</div>
<div style={{ display: 'flex', border: '1px solid var(--border-primary)', borderRadius: 10, overflow: 'hidden', background: 'var(--bg-input)' }}>
<div style={{
padding: '10px 0', fontSize: 13, fontFamily: 'monospace', lineHeight: 1.5,
color: 'var(--text-faint)', textAlign: 'right', userSelect: 'none',
background: 'var(--bg-hover)', borderRight: '1px solid var(--border-faint)',
minWidth: 32, flexShrink: 0,
}}>
{(importText || ' ').split('\n').map((_, i) => (
<div key={i} style={{ padding: '0 6px' }}>{i + 1}</div>
))}
</div>
<textarea
value={importText}
onChange={e => setImportText(e.target.value)}
rows={10}
placeholder={t('packing.importPlaceholder')}
style={{
flex: 1, border: 'none', padding: '10px 12px', fontSize: 13, fontFamily: 'monospace',
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)',
background: 'transparent', resize: 'vertical', lineHeight: 1.5,
}}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<input ref={csvInputRef} type="file" accept=".csv,.txt" style={{ display: 'none' }} onChange={handleCsvFile} />
<button onClick={() => csvInputRef.current?.click()} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 10px',
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
fontSize: 11, color: 'var(--text-faint)', cursor: 'pointer', fontFamily: 'inherit',
}}>
<Upload size={11} /> {t('packing.importCsv')}
</button>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => setShowImportModal(false)} style={{
fontSize: 12, background: 'none', border: '1px solid var(--border-primary)',
borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit',
}}>{t('common.cancel')}</button>
<button onClick={handleBulkImport} disabled={!importText.trim()} style={{
fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)',
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600,
fontFamily: 'inherit', opacity: importText.trim() ? 1 : 0.5,
}}>{t('packing.importAction', { count: parseImportLines(importText).length })}</button>
</div>
</div>
</div>
</div>,
document.body
)
}
export default function PackingListPanel(props: PackingListPanelProps) {
const S = usePackingList(props)
const { font, bagTrackingEnabled, bags, showBagModal, showImportModal } = S
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
{/* ── Header ── */}
<PackingHeader {...S} />
{/* ── Filter-Tabs ── */}
{items.length > 0 && (
<div style={{ display: 'flex', gap: 4, padding: '10px 0 0', flexShrink: 0 }}>
{[['alle', t('packing.filterAll')], ['offen', t('packing.filterOpen')], ['erledigt', t('packing.filterDone')]].map(([id, label]) => (
<button key={id} onClick={() => setFilter(id)} style={{
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer',
fontSize: 12, fontFamily: 'inherit', fontWeight: filter === id ? 600 : 400,
background: filter === id ? 'var(--text-primary)' : 'transparent',
color: filter === id ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>{label}</button>
))}
</div>
)}
<PackingFilterTabs {...S} />
{/* ── Liste + Bags Sidebar ── */}
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}>
{items.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
<Luggage size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 10px' }} />
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('packing.emptyTitle')}</p>
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('packing.emptyHint')}</p>
</div>
) : Object.keys(gruppiert).length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--text-faint)' }}>
<p style={{ fontSize: 13, margin: 0 }}>{t('packing.emptyFiltered')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{Object.entries(gruppiert).map(([kat, katItems]) => (
<KategorieGruppe
key={kat}
kategorie={kat}
items={katItems}
tripId={tripId}
allCategories={allCategories}
onRename={handleRenameCategory}
onDeleteAll={handleDeleteCategory}
onAddItem={handleAddItemToCategory}
assignees={categoryAssignees[kat] || []}
tripMembers={tripMembers}
onSetAssignees={handleSetAssignees}
bagTrackingEnabled={bagTrackingEnabled}
bags={bags}
onCreateBag={handleCreateBagByName}
canEdit={canEdit}
/>
))}
</div>
)}
</div>
{/* ── Bag Weight Sidebar ── */}
{bagTrackingEnabled && bags.length > 0 && (
<div className="hidden xl:block" style={{ width: 260, borderLeft: '1px solid var(--border-secondary)', overflowY: 'auto', padding: 16, flexShrink: 0 }}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-faint)', marginBottom: 12 }}>
{t('packing.bags')}
</div>
{bags.map(bag => {
const bagItems = items.filter(i => i.bag_id === bag.id)
const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0)
const maxWeight = bag.weight_limit_grams || Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1)
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
return (
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact />
)
})}
{/* Unassigned */}
{(() => {
const unassigned = items.filter(i => !i.bag_id)
const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0)
if (unassigned.length === 0) return null
return (
<div style={{ marginBottom: 14, opacity: 0.6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', border: '2px dashed var(--border-primary)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12, fontWeight: 600, color: 'var(--text-faint)' }}>{t('packing.noBag')}</span>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>
{unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`}
</span>
</div>
<div style={{ fontSize: 10, color: 'var(--text-faint)' }}>{unassigned.length} {t('admin.packingTemplates.items')}</div>
</div>
)
})()}
{/* Total */}
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 10, marginTop: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, fontWeight: 700, color: 'var(--text-primary)' }}>
<span>{t('packing.totalWeight')}</span>
<span>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
</div>
</div>
{/* Add bag */}
{canEdit && (showAddBag ? (
<div style={{ display: 'flex', gap: 4, marginTop: 12 }}>
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
placeholder={t('packing.bagName')}
style={{ flex: 1, padding: '5px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 11, fontFamily: 'inherit', outline: 'none' }} />
<button onClick={handleCreateBag} style={{ padding: '4px 8px', borderRadius: 8, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
<Plus size={12} />
</button>
</div>
) : (
<button onClick={() => setShowAddBag(true)}
style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 12, padding: '5px 8px', borderRadius: 8, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%' }}>
<Plus size={11} /> {t('packing.addBag')}
</button>
))}
</div>
)}
<PackingList {...S} />
{bagTrackingEnabled && bags.length > 0 && <BagSidebar {...S} />}
</div>
{/* ── Bag Modal (mobile + click) ── */}
{showBagModal && bagTrackingEnabled && (
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflowY: 'auto' }}
onClick={() => setShowBagModal(false)}>
<div style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 360, maxHeight: 'calc(100vh - 80px)', overflow: 'auto', padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.15)', flexShrink: 0 }}
onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.bags')}</h3>
<button onClick={() => setShowBagModal(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><X size={18} /></button>
</div>
{bags.map(bag => {
const bagItems = items.filter(i => i.bag_id === bag.id)
const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0)
const maxWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1)
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
return (
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} />
)
})}
{/* Unassigned */}
{(() => {
const unassigned = items.filter(i => !i.bag_id)
const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0)
if (unassigned.length === 0) return null
return (
<div style={{ marginBottom: 16, opacity: 0.6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span style={{ width: 12, height: 12, borderRadius: '50%', border: '2px dashed var(--border-primary)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 14, fontWeight: 600, color: 'var(--text-faint)' }}>{t('packing.noBag')}</span>
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
{unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`}
</span>
</div>
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{unassigned.length} {t('admin.packingTemplates.items')}</div>
</div>
)
})()}
{/* Total */}
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 12, marginTop: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>
<span>{t('packing.totalWeight')}</span>
<span>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
</div>
</div>
{/* Add bag */}
{canEdit && (showAddBag ? (
<div style={{ display: 'flex', gap: 6, marginTop: 14 }}>
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
placeholder={t('packing.bagName')}
style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none' }} />
<button onClick={handleCreateBag} disabled={!newBagName.trim()}
style={{ padding: '8px 12px', borderRadius: 10, border: 'none', background: newBagName.trim() ? 'var(--text-primary)' : 'var(--border-primary)', color: 'var(--bg-primary)', cursor: newBagName.trim() ? 'pointer' : 'default', display: 'flex', alignItems: 'center' }}>
<Plus size={14} />
</button>
</div>
) : (
<button onClick={() => setShowAddBag(true)}
style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 14, padding: '9px 14px', borderRadius: 10, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%', transition: 'all 0.15s' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-muted)'; e.currentTarget.style.color = 'var(--text-secondary)' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
<Plus size={14} /> {t('packing.addBag')}
</button>
))}
</div>
</div>
)}
{showBagModal && bagTrackingEnabled && <BagModal {...S} />}
<style>{`
.assignee-chip:hover + .assignee-tooltip { opacity: 1 !important; }
@@ -1449,69 +1586,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
`}</style>
{/* Bulk Import Modal */}
{showImportModal && ReactDOM.createPortal(
<div style={{
position: 'fixed', inset: 0, zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
}} onClick={() => setShowImportModal(false)}>
<div style={{
width: 420, maxHeight: '80vh', background: 'var(--bg-card)', borderRadius: 16,
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
display: 'flex', flexDirection: 'column', gap: 14,
}} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{t('packing.importTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)', lineHeight: 1.5 }}>{t('packing.importHint')}</div>
<div style={{ display: 'flex', border: '1px solid var(--border-primary)', borderRadius: 10, overflow: 'hidden', background: 'var(--bg-input)' }}>
<div style={{
padding: '10px 0', fontSize: 13, fontFamily: 'monospace', lineHeight: 1.5,
color: 'var(--text-faint)', textAlign: 'right', userSelect: 'none',
background: 'var(--bg-hover)', borderRight: '1px solid var(--border-faint)',
minWidth: 32, flexShrink: 0,
}}>
{(importText || ' ').split('\n').map((_, i) => (
<div key={i} style={{ padding: '0 6px' }}>{i + 1}</div>
))}
</div>
<textarea
value={importText}
onChange={e => setImportText(e.target.value)}
rows={10}
placeholder={t('packing.importPlaceholder')}
style={{
flex: 1, border: 'none', padding: '10px 12px', fontSize: 13, fontFamily: 'monospace',
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)',
background: 'transparent', resize: 'vertical', lineHeight: 1.5,
}}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<input ref={csvInputRef} type="file" accept=".csv,.txt" style={{ display: 'none' }} onChange={handleCsvFile} />
<button onClick={() => csvInputRef.current?.click()} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 10px',
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
fontSize: 11, color: 'var(--text-faint)', cursor: 'pointer', fontFamily: 'inherit',
}}>
<Upload size={11} /> {t('packing.importCsv')}
</button>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => setShowImportModal(false)} style={{
fontSize: 12, background: 'none', border: '1px solid var(--border-primary)',
borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit',
}}>{t('common.cancel')}</button>
<button onClick={handleBulkImport} disabled={!importText.trim()} style={{
fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)',
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600,
fontFamily: 'inherit', opacity: importText.trim() ? 1 : 0.5,
}}>{t('packing.importAction', { count: parseImportLines(importText).length })}</button>
</div>
</div>
</div>
</div>,
document.body
)}
{showImportModal && <BulkImportModal {...S} />}
</div>
)
}
+116 -164
View File
@@ -14,6 +14,7 @@ import { getLocaleForLanguage, useTranslation } from '../../i18n'
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
import { isDayInAccommodationRange } from '../../utils/dayOrder'
import { splitReservationDateTime } from '../../utils/formatters'
import { useDayDetail } from './useDayDetail'
const WEATHER_ICON_MAP = {
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
@@ -77,92 +78,13 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const unit = isFahrenheit ? '°F' : '°C'
const collapsed = collapsedProp
const toggleCollapse = () => onToggleCollapse?.()
const [weather, setWeather] = useState(null)
const [loading, setLoading] = useState(false)
const [accommodation, setAccommodation] = useState(null)
const [dayAccommodations, setDayAccommodations] = useState<any[]>([])
const [accommodations, setAccommodations] = useState([])
const [showHotelPicker, setShowHotelPicker] = useState(false)
const [hotelDayRange, setHotelDayRange] = useState({ start: day?.id, end: day?.id })
const [hotelCategoryFilter, setHotelCategoryFilter] = useState('')
const [hotelForm, setHotelForm] = useState({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
useEffect(() => {
if (!day?.date || !lat || !lng) { setWeather(null); return }
setLoading(true)
weatherApi.getDetailed(lat, lng, day.date, language)
.then(data => setWeather(data.error ? null : data))
.catch(() => setWeather(null))
.finally(() => setLoading(false))
}, [day?.date, lat, lng, language])
useEffect(() => {
if (!tripId) return
accommodationsApi.list(tripId)
.then(data => {
setAccommodations(data.accommodations || [])
const allForDay = (data.accommodations || []).filter(a =>
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
)
setDayAccommodations(allForDay)
setAccommodation(allForDay[0] || null)
})
.catch(() => {})
}, [tripId, day?.id])
useEffect(() => { if (day) setHotelDayRange({ start: day.id, end: day.id }) }, [day?.id])
const handleSelectPlace = (placeId) => {
setHotelForm(f => ({ ...f, place_id: placeId }))
}
const handleSaveAccommodation = async () => {
if (!hotelForm.place_id) return
try {
const data = await accommodationsApi.create(tripId, {
place_id: hotelForm.place_id,
start_day_id: hotelDayRange.start,
end_day_id: hotelDayRange.end,
check_in: hotelForm.check_in || null,
check_in_end: hotelForm.check_in_end || null,
check_out: hotelForm.check_out || null,
confirmation: hotelForm.confirmation || null,
})
const newAcc = data.accommodation
const updated = [...accommodations, newAcc]
setAccommodations(updated)
setAccommodation(newAcc)
setDayAccommodations(updated.filter(a =>
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
))
setShowHotelPicker(false)
setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
onAccommodationChange?.()
} catch {}
}
const updateAccommodationField = async (field, value) => {
if (!accommodation) return
try {
const data = await accommodationsApi.update(tripId, accommodation.id, { [field]: value || null })
setAccommodation(data.accommodation)
onAccommodationChange?.()
} catch {}
}
const handleRemoveAccommodation = async () => {
if (!accommodation) return
try {
await accommodationsApi.delete(tripId, accommodation.id)
const updated = accommodations.filter(a => a.id !== accommodation.id)
setAccommodations(updated)
setDayAccommodations(updated.filter(a =>
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
))
setAccommodation(null)
onAccommodationChange?.()
} catch {}
}
const {
weather, loading, accommodation, setAccommodation, dayAccommodations, setDayAccommodations,
accommodations, setAccommodations, showHotelPicker, setShowHotelPicker,
hotelDayRange, setHotelDayRange, hotelCategoryFilter, setHotelCategoryFilter,
hotelForm, setHotelForm, handleSelectPlace, handleSaveAccommodation,
updateAccommodationField, handleRemoveAccommodation,
} = useDayDetail(day, days, tripId, lat, lng, language, onAccommodationChange)
if (!day) return null
@@ -337,6 +259,104 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>{t('day.accommodation')}</div>
<AccommodationList dayAccommodations={dayAccommodations} day={day} reservations={reservations}
canEditDays={canEditDays} fmtTime={fmtTime} blurCodes={blurCodes} t={t}
setAccommodation={setAccommodation} setHotelForm={setHotelForm} setHotelDayRange={setHotelDayRange}
setShowHotelPicker={setShowHotelPicker} handleRemoveAccommodation={handleRemoveAccommodation} />
<HotelPickerModal showHotelPicker={showHotelPicker} setShowHotelPicker={setShowHotelPicker}
font={font} t={t} hotelDayRange={hotelDayRange} setHotelDayRange={setHotelDayRange} days={days} locale={locale}
hotelForm={hotelForm} setHotelForm={setHotelForm} categories={categories} hotelCategoryFilter={hotelCategoryFilter}
setHotelCategoryFilter={setHotelCategoryFilter} places={places} handleSelectPlace={handleSelectPlace}
accommodation={accommodation} tripId={tripId} day={day} setAccommodations={setAccommodations}
setDayAccommodations={setDayAccommodations} setAccommodation={setAccommodation}
handleSaveAccommodation={handleSaveAccommodation} onAccommodationChange={onAccommodationChange} />
</div>
</div>
</div>
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
}
interface ChipProps {
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
value: string
}
function Chip({ icon: Icon, value }: ChipProps) {
return (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
<Icon size={11} style={{ flexShrink: 0, opacity: 0.6 }} />
<span style={{ fontWeight: 500 }}>{value}</span>
</div>
)
}
interface InfoChipProps {
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
label: string
value: string
placeholder: string
onEdit: (value: string) => void
type: 'text' | 'time'
}
function InfoChip({ icon: Icon, label, value, placeholder, onEdit, type }: InfoChipProps) {
const [editing, setEditing] = React.useState(false)
const [val, setVal] = React.useState(value || '')
const inputRef = React.useRef(null)
React.useEffect(() => { setVal(value || '') }, [value])
React.useEffect(() => { if (editing && inputRef.current) inputRef.current.focus() }, [editing])
const save = () => {
setEditing(false)
if (val !== (value || '')) onEdit(val)
}
return (
<div
onClick={() => setEditing(true)}
style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8,
background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
cursor: 'pointer', minWidth: 0, flex: type === 'text' ? 1 : undefined,
}}
>
<Icon size={11} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 8, color: 'var(--text-faint)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', lineHeight: 1 }}>{label}</div>
{editing ? (
<input
ref={inputRef}
type={type}
value={val}
onChange={e => setVal(e.target.value)}
onBlur={save}
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setVal(value || ''); setEditing(false) } }}
onClick={e => e.stopPropagation()}
style={{
border: 'none', outline: 'none', background: 'none', padding: 0, margin: 0,
fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', fontFamily: 'inherit',
width: type === 'time' ? 50 : '100%', lineHeight: 1.3,
}}
/>
) : (
<div style={{ fontSize: 11, fontWeight: 600, color: value ? 'var(--text-primary)' : 'var(--text-faint)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{value || placeholder}
</div>
)}
</div>
</div>
)
}
function AccommodationList({ dayAccommodations, day, reservations, canEditDays, fmtTime, blurCodes, t,
setAccommodation, setHotelForm, setHotelDayRange, setShowHotelPicker, handleRemoveAccommodation }: any) {
return (
<>
{dayAccommodations.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{dayAccommodations.map(acc => {
@@ -449,7 +469,16 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<Hotel size={12} /> {t('day.addAccommodation')}
</button> : null
)}
</>
)
}
function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelDayRange, setHotelDayRange,
days, locale, hotelForm, setHotelForm, categories, hotelCategoryFilter, setHotelCategoryFilter, places,
handleSelectPlace, accommodation, tripId, day, setAccommodations, setDayAccommodations, setAccommodation,
handleSaveAccommodation, onAccommodationChange }: any) {
return (
<>
{/* Hotel Picker Popup — portal to body to escape transform stacking context */}
{showHotelPicker && ReactDOM.createPortal(
<div style={{ position: 'fixed', inset: 0, zIndex: 99999, background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
@@ -632,83 +661,6 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
</div>,
document.body
)}
</div>
</div>
</div>
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
}
interface ChipProps {
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
value: string
}
function Chip({ icon: Icon, value }: ChipProps) {
return (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
<Icon size={11} style={{ flexShrink: 0, opacity: 0.6 }} />
<span style={{ fontWeight: 500 }}>{value}</span>
</div>
)
}
interface InfoChipProps {
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
label: string
value: string
placeholder: string
onEdit: (value: string) => void
type: 'text' | 'time'
}
function InfoChip({ icon: Icon, label, value, placeholder, onEdit, type }: InfoChipProps) {
const [editing, setEditing] = React.useState(false)
const [val, setVal] = React.useState(value || '')
const inputRef = React.useRef(null)
React.useEffect(() => { setVal(value || '') }, [value])
React.useEffect(() => { if (editing && inputRef.current) inputRef.current.focus() }, [editing])
const save = () => {
setEditing(false)
if (val !== (value || '')) onEdit(val)
}
return (
<div
onClick={() => setEditing(true)}
style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8,
background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
cursor: 'pointer', minWidth: 0, flex: type === 'text' ? 1 : undefined,
}}
>
<Icon size={11} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 8, color: 'var(--text-faint)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', lineHeight: 1 }}>{label}</div>
{editing ? (
<input
ref={inputRef}
type={type}
value={val}
onChange={e => setVal(e.target.value)}
onBlur={save}
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setVal(value || ''); setEditing(false) } }}
onClick={e => e.stopPropagation()}
style={{
border: 'none', outline: 'none', background: 'none', padding: 0, margin: 0,
fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', fontFamily: 'inherit',
width: type === 'time' ? 50 : '100%', lineHeight: 1.3,
}}
/>
) : (
<div style={{ fontSize: 11, fontWeight: 600, color: value ? 'var(--text-primary)' : 'var(--text-faint)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{value || placeholder}
</div>
)}
</div>
</div>
</>
)
}
@@ -223,7 +223,15 @@ function RouteConnector({ seg, profile }: { seg: RouteSegment; profile: 'driving
)
}
const DayPlanSidebar = React.memo(function DayPlanSidebar({
/**
* Day-plan state + behaviour: expand/collapse, inline title edit, route legs +
* optimisation, day notes, and the drag-and-drop reorder/move machinery across
* days (places, transports, notes). Returns everything the timeline view renders
* from, keeping DayPlanSidebar a thin shell over one large day list.
*/
function useDayPlanSidebar(props: DayPlanSidebarProps) {
const {
tripId,
trip, days, places, categories, assignments,
selectedDayId, selectedPlaceId, selectedAssignmentId,
@@ -255,7 +263,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onAddBookingToAssignment,
initialScrollTop,
onScrollTopChange,
}: DayPlanSidebarProps) {
} = props
const toast = useToast()
const { t, language, locale } = useTranslation()
const ctxMenu = useContextMenu()
@@ -920,6 +928,286 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const anyGeoAssignment = Object.values(assignments).flatMap(da => da).find(a => a.place?.lat && a.place?.lng)
const anyGeoPlace = anyGeoAssignment || (places || []).find(p => p.lat && p.lng)
return {
tripId,
trip,
days,
places,
categories,
assignments,
selectedDayId,
selectedPlaceId,
selectedAssignmentId,
onSelectDay,
onPlaceClick,
onDayDetail,
accommodations,
onReorder,
onUpdateDayTitle,
onRouteCalculated,
onAssignToDay,
onRemoveAssignment,
onEditPlace,
onDeletePlace,
reservations,
visibleConnectionIds,
onToggleConnection,
externalTransportDetail,
onExternalTransportDetailHandled,
onAddReservation,
onAddPlace,
onAddPlaceToDay,
onNavigateToFiles,
routeShown,
routeProfile,
onToggleRoute,
onSetRouteProfile,
onExpandedDaysChange,
pushUndo,
canUndo,
lastActionLabel,
onUndo,
onRouteRefresh,
onAddTransport,
onEditTransport,
onEditReservation,
onAddBookingToAssignment,
initialScrollTop,
onScrollTopChange,
toast,
t,
language,
locale,
ctxMenu,
timeFormat,
tripActions,
can,
canEditDays,
noteUi,
setNoteUi,
noteInputRef,
dayNotes,
openAddNote,
openEditNote,
cancelNote,
saveNote,
deleteNote,
moveNote,
expandedDays,
setExpandedDays,
editingDayId,
setEditingDayId,
editTitle,
setEditTitle,
isCalculating,
setIsCalculating,
routeInfo,
setRouteInfo,
routeLegs,
setRouteLegs,
legsAbortRef,
draggingId,
setDraggingId,
lockedIds,
setLockedIds,
lockHoverId,
setLockHoverId,
undoHover,
setUndoHover,
pdfHover,
setPdfHover,
icsHover,
setIcsHover,
hoveredAssignmentId,
setHoveredAssignmentId,
dropTargetKey,
_setDropTargetKey,
dropTargetRef,
setDropTargetKey,
dragOverDayId,
setDragOverDayId,
transportDetail,
setTransportDetail,
transportPosVersion,
setTransportPosVersion,
timeConfirm,
setTimeConfirm,
inputRef,
dragDataRef,
scrollContainerRef,
initedTransportIds,
lastAutoScrolledIdRef,
currency,
getDragData,
prevDayCount,
toggleDay,
getSpanLabel,
getDayOrder,
computeMultiDayMove,
getTransportForDay,
getActiveRentalsForDay,
getDayAssignments,
computeTransportPosition,
initTransportPositions,
getMergedItems,
mergedItemsMap,
wouldBreakChronology,
applyMergedOrder,
handleMergedDrop,
confirmTimeRemoval,
startEditTitle,
saveTitle,
handleCalculateRoute,
toggleLock,
handleOptimize,
handleDropOnDay,
handleDropOnRow,
totalCost,
anyGeoAssignment,
anyGeoPlace,
}
}
const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarProps) {
const S = useDayPlanSidebar(props)
const {
tripId,
trip,
days,
places,
categories,
assignments,
selectedDayId,
selectedPlaceId,
selectedAssignmentId,
onSelectDay,
onPlaceClick,
onDayDetail,
accommodations,
onReorder,
onUpdateDayTitle,
onRouteCalculated,
onAssignToDay,
onRemoveAssignment,
onEditPlace,
onDeletePlace,
reservations,
visibleConnectionIds,
onToggleConnection,
externalTransportDetail,
onExternalTransportDetailHandled,
onAddReservation,
onAddPlace,
onAddPlaceToDay,
onNavigateToFiles,
routeShown,
routeProfile,
onToggleRoute,
onSetRouteProfile,
onExpandedDaysChange,
pushUndo,
canUndo,
lastActionLabel,
onUndo,
onRouteRefresh,
onAddTransport,
onEditTransport,
onEditReservation,
onAddBookingToAssignment,
initialScrollTop,
onScrollTopChange,
toast,
t,
language,
locale,
ctxMenu,
timeFormat,
tripActions,
can,
canEditDays,
noteUi,
setNoteUi,
noteInputRef,
dayNotes,
openAddNote,
openEditNote,
cancelNote,
saveNote,
deleteNote,
moveNote,
expandedDays,
setExpandedDays,
editingDayId,
setEditingDayId,
editTitle,
setEditTitle,
isCalculating,
setIsCalculating,
routeInfo,
setRouteInfo,
routeLegs,
setRouteLegs,
legsAbortRef,
draggingId,
setDraggingId,
lockedIds,
setLockedIds,
lockHoverId,
setLockHoverId,
undoHover,
setUndoHover,
pdfHover,
setPdfHover,
icsHover,
setIcsHover,
hoveredAssignmentId,
setHoveredAssignmentId,
dropTargetKey,
_setDropTargetKey,
dropTargetRef,
setDropTargetKey,
dragOverDayId,
setDragOverDayId,
transportDetail,
setTransportDetail,
transportPosVersion,
setTransportPosVersion,
timeConfirm,
setTimeConfirm,
inputRef,
dragDataRef,
scrollContainerRef,
initedTransportIds,
lastAutoScrolledIdRef,
currency,
getDragData,
prevDayCount,
toggleDay,
getSpanLabel,
getDayOrder,
computeMultiDayMove,
getTransportForDay,
getActiveRentalsForDay,
getDayAssignments,
computeTransportPosition,
initTransportPositions,
getMergedItems,
mergedItemsMap,
wouldBreakChronology,
applyMergedOrder,
handleMergedDrop,
confirmTimeRemoval,
startEditTitle,
saveTitle,
handleCalculateRoute,
toggleLock,
handleOptimize,
handleDropOnDay,
handleDropOnRow,
totalCost,
anyGeoAssignment,
anyGeoPlace,
} = S
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
{/* Toolbar */}
@@ -2336,4 +2624,4 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
)
})
export default DayPlanSidebar
export default DayPlanSidebar
@@ -71,10 +71,15 @@ interface PlaceFormModalProps {
dayAssignments?: Assignment[]
}
export default function PlaceFormModal({
/** Place create/edit form state: maps search + Google-URL resolve + autocomplete,
* category creation, file attachments and submit. Keeps PlaceFormModal a thin
* render over the form fields. */
function usePlaceFormModal(props: PlaceFormModalProps) {
const {
isOpen, onClose, onSave, place, prefillCoords, tripId, categories,
onCategoryCreated, assignmentId, dayAssignments = [],
}: PlaceFormModalProps) {
} = props
const [form, setForm] = useState(DEFAULT_FORM)
const [mapsSearch, setMapsSearch] = useState('')
const [mapsResults, setMapsResults] = useState([])
@@ -354,6 +359,122 @@ export default function PlaceFormModal({
}
}
return {
isOpen,
onClose,
onSave,
place,
prefillCoords,
tripId,
categories,
onCategoryCreated,
assignmentId,
dayAssignments,
form,
setForm,
mapsSearch,
setMapsSearch,
mapsResults,
setMapsResults,
isSearchingMaps,
setIsSearchingMaps,
newCategoryName,
setNewCategoryName,
showNewCategory,
setShowNewCategory,
isSaving,
setIsSaving,
pendingFiles,
setPendingFiles,
fileRef,
acSuggestions,
setAcSuggestions,
acHighlight,
setAcHighlight,
acDebounceRef,
acAbortRef,
toast,
t,
language,
hasMapsKey,
can,
tripObj,
canUploadFiles,
places,
locationBias,
fetchSuggestions,
handleChange,
handleMapsSearch,
handleSelectMapsResult,
handleSelectSuggestion,
handleSearchKeyDown,
handleCreateCategory,
handleFileAdd,
handleRemoveFile,
handlePaste,
hasTimeError,
handleSubmit,
}
}
export default function PlaceFormModal(props: PlaceFormModalProps) {
const S = usePlaceFormModal(props)
const {
isOpen,
onClose,
onSave,
place,
prefillCoords,
tripId,
categories,
onCategoryCreated,
assignmentId,
dayAssignments,
form,
setForm,
mapsSearch,
setMapsSearch,
mapsResults,
setMapsResults,
isSearchingMaps,
setIsSearchingMaps,
newCategoryName,
setNewCategoryName,
showNewCategory,
setShowNewCategory,
isSaving,
setIsSaving,
pendingFiles,
setPendingFiles,
fileRef,
acSuggestions,
setAcSuggestions,
acHighlight,
setAcHighlight,
acDebounceRef,
acAbortRef,
toast,
t,
language,
hasMapsKey,
can,
tripObj,
canUploadFiles,
places,
locationBias,
fetchSuggestions,
handleChange,
handleMapsSearch,
handleSelectMapsResult,
handleSelectSuggestion,
handleSearchKeyDown,
handleCreateCategory,
handleFileAdd,
handleRemoveFile,
handlePaste,
hasTimeError,
handleSubmit,
} = S
return (
<Modal
isOpen={isOpen}
+367 -335
View File
@@ -226,90 +226,10 @@ export default function PlaceInspector({
flexDirection: 'column',
}}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: openNow !== null ? 26 : 14, padding: openNow !== null ? '18px 16px 14px 28px' : '18px 16px 14px', borderBottom: '1px solid var(--border-faint)' }}>
{/* Avatar with open/closed ring + tag */}
<div style={{ position: 'relative', flexShrink: 0, marginBottom: openNow !== null ? 8 : 0 }}>
<div style={{
borderRadius: '50%', padding: 2.5,
background: openNow === true ? '#22c55e' : openNow === false ? '#ef4444' : 'transparent',
}}>
<PlaceAvatar place={place} category={category} size={52} />
</div>
{openNow !== null && (
<span style={{
position: 'absolute', bottom: -7, left: '50%', transform: 'translateX(-50%)',
fontSize: 9, fontWeight: 500, letterSpacing: '0.02em',
color: 'white',
background: openNow ? '#16a34a' : '#dc2626',
padding: '1.5px 7px', borderRadius: 99,
whiteSpace: 'nowrap',
boxShadow: '0 1px 4px rgba(0,0,0,0.2)',
}}>
{openNow ? t('inspector.opened') : t('inspector.closed')}
</span>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
{editingName ? (
<input
ref={nameInputRef}
value={nameValue}
onChange={e => setNameValue(e.target.value)}
onBlur={commitNameEdit}
onKeyDown={handleNameKeyDown}
style={{ fontWeight: 600, fontSize: 15, color: 'var(--text-primary)', lineHeight: '1.3', background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, padding: '1px 6px', fontFamily: 'inherit', outline: 'none', width: '100%' }}
/>
) : (
<span
onDoubleClick={startNameEdit}
style={{ fontWeight: 600, fontSize: 15, color: 'var(--text-primary)', lineHeight: '1.3', cursor: onUpdatePlace ? 'text' : 'default' }}
>{place.name}</span>
)}
{category && (() => {
const CatIcon = getCategoryIcon(category.icon)
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: 11, fontWeight: 500,
color: category.color || '#6b7280',
background: category.color ? `${category.color}18` : 'rgba(0,0,0,0.06)',
border: `1px solid ${category.color ? `${category.color}30` : 'transparent'}`,
padding: '2px 8px', borderRadius: 99,
}}>
<CatIcon size={10} />
<span className="hidden sm:inline">{category.name}</span>
</span>
)
})()}
</div>
{place.address && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4, marginTop: 6 }}>
<MapPin size={11} color="var(--text-faint)" style={{ flexShrink: 0, marginTop: 2 }} />
<span style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.4', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{place.address}</span>
</div>
)}
{place.place_time && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 3 }}>
<Clock size={10} color="var(--text-faint)" style={{ flexShrink: 0 }} />
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{formatTime(place.place_time, locale, timeFormat)}{place.end_time ? ` ${formatTime(place.end_time, locale, timeFormat)}` : ''}</span>
</div>
)}
{place.lat && place.lng && (
<div className="hidden sm:block" style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 4, fontVariantNumeric: 'tabular-nums' }}>
{Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)}
</div>
)}
</div>
<button
onClick={onClose}
style={{ width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-hover)', border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0, alignSelf: 'flex-start', transition: 'background 0.15s' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-hover)'}
>
<X size={14} strokeWidth={2} color="var(--text-secondary)" />
</button>
</div>
<PlaceInspectorHeader openNow={openNow} place={place} category={category} t={t} editingName={editingName}
nameInputRef={nameInputRef} nameValue={nameValue} setNameValue={setNameValue} commitNameEdit={commitNameEdit}
handleNameKeyDown={handleNameKeyDown} startNameEdit={startNameEdit} onUpdatePlace={onUpdatePlace}
locale={locale} timeFormat={timeFormat} onClose={onClose} />
{/* Content — scrollable */}
<div style={{ overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
@@ -360,259 +280,15 @@ export default function PlaceInspector({
)}
{/* Reservation + Participants — side by side */}
{(() => {
const res = selectedAssignmentId ? reservations.find(r => r.assignment_id === selectedAssignmentId) : null
const assignment = selectedAssignmentId ? (assignments[String(selectedDayId)] || []).find(a => a.id === selectedAssignmentId) : null
const currentParticipants = assignment?.participants || []
const participantIds = currentParticipants.map(p => p.user_id)
const allJoined = currentParticipants.length === 0
const showParticipants = selectedAssignmentId && tripMembers.length > 1
if (!res && !showParticipants) return null
return (
<div className={`grid ${res && showParticipants ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'} gap-2`}>
{/* Reservation */}
{res && (() => {
const confirmed = res.status === 'confirmed'
return (
<div style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: confirmed ? 'rgba(22,163,74,0.08)' : 'rgba(217,119,6,0.08)' }}>
<div style={{ width: 6, height: 6, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
<span style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706' }}>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
<span style={{ flex: 1 }} />
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span>
</div>
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{(() => {
const { date, time: startTime } = splitReservationDateTime(res.reservation_time)
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
return (
<>
{date && (
<div>
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
</div>
)}
{(startTime || endTime) && (
<div>
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.time')}</div>
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
{startTime ? formatTime(startTime, locale, timeFormat) : ''}
{endTime ? ` ${formatTime(endTime, locale, timeFormat)}` : ''}
</div>
</div>
)}
</>
)
})()}
{res.confirmation_number && (
<div>
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{res.confirmation_number}</div>
</div>
)}
</div>
{res.notes && <div className="collab-note-md" style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4, wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{res.notes}</Markdown></div>}
{(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta || Object.keys(meta).length === 0) return null
const parts: string[] = []
if (meta.airline && meta.flight_number) parts.push(`${meta.airline} ${meta.flight_number}`)
else if (meta.flight_number) parts.push(meta.flight_number)
if (meta.departure_airport && meta.arrival_airport) parts.push(`${meta.departure_airport}${meta.arrival_airport}`)
if (meta.train_number) parts.push(meta.train_number)
if (meta.platform) parts.push(`Gl. ${meta.platform}`)
if (meta.check_in_time) parts.push(`Check-in ${meta.check_in_time}`)
if (meta.check_out_time) parts.push(`Check-out ${meta.check_out_time}`)
if (parts.length === 0) return null
return <div style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-muted)', fontWeight: 500 }}>{parts.join(' · ')}</div>
})()}
</div>
)
})()}
{/* Participants */}
{showParticipants && (
<ParticipantsBox
tripMembers={tripMembers}
participantIds={participantIds}
allJoined={allJoined}
onSetParticipants={onSetParticipants}
selectedAssignmentId={selectedAssignmentId}
selectedDayId={selectedDayId}
t={t}
/>
)}
</div>
)
})()}
<PlaceReservationParticipants selectedAssignmentId={selectedAssignmentId} reservations={reservations}
assignments={assignments} selectedDayId={selectedDayId} tripMembers={tripMembers} locale={locale}
timeFormat={timeFormat} t={t} onSetParticipants={onSetParticipants} />
{/* Opening hours + Files — side by side on desktop only if both exist */}
<div className={`grid grid-cols-1 ${openingHours?.length > 0 ? 'sm:grid-cols-2' : ''} gap-2`}>
{openingHours && openingHours.length > 0 && (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
<button
onClick={() => setHoursExpanded(h => !h)}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '8px 12px', background: 'none', border: 'none', cursor: 'pointer',
fontFamily: 'inherit',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Clock size={13} color="#9ca3af" />
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>
{hoursExpanded ? t('inspector.openingHours') : (convertHoursLine(openingHours[weekdayIndex] || '', timeFormat) || t('inspector.showHours'))}
</span>
</div>
{hoursExpanded ? <ChevronUp size={13} color="#9ca3af" /> : <ChevronDown size={13} color="#9ca3af" />}
</button>
{hoursExpanded && (
<div style={{ padding: '0 12px 10px' }}>
{openingHours.map((line, i) => (
<div key={i} style={{
fontSize: 12, color: i === weekdayIndex ? 'var(--text-primary)' : 'var(--text-muted)',
fontWeight: i === weekdayIndex ? 600 : 400,
padding: '2px 0',
}}>{convertHoursLine(line, timeFormat)}</div>
))}
</div>
)}
</div>
)}
{/* GPX Track stats */}
{place.route_geometry && (() => {
try {
const pts: number[][] = JSON.parse(place.route_geometry)
if (!pts || pts.length < 2) return null
const hasEle = pts[0].length >= 3
// Haversine distance
const toRad = (d: number) => d * Math.PI / 180
let totalDist = 0
for (let i = 1; i < pts.length; i++) {
const [lat1, lng1] = pts[i - 1], [lat2, lng2] = pts[i]
const dLat = toRad(lat2 - lat1), dLng = toRad(lng2 - lng1)
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2
totalDist += 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}
const distKm = totalDist / 1000
// Elevation stats
let minEle = Infinity, maxEle = -Infinity, totalUp = 0, totalDown = 0
if (hasEle) {
for (let i = 0; i < pts.length; i++) {
const e = pts[i][2]
if (e < minEle) minEle = e
if (e > maxEle) maxEle = e
if (i > 0) {
const diff = e - pts[i - 1][2]
if (diff > 0) totalUp += diff; else totalDown += Math.abs(diff)
}
}
}
// Elevation profile SVG
const chartW = 280, chartH = 60
const elevations = hasEle ? pts.map(p => p[2]) : []
let pathD = ''
if (elevations.length > 1) {
const step = Math.max(1, Math.floor(elevations.length / chartW))
const sampled = elevations.filter((_, i) => i % step === 0)
const eMin = Math.min(...sampled), eMax = Math.max(...sampled)
const range = eMax - eMin || 1
pathD = sampled.map((e, i) => {
const x = (i / (sampled.length - 1)) * chartW
const y = chartH - ((e - eMin) / range) * (chartH - 4) - 2
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
}).join(' ')
}
return (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<TrendingUp size={13} color="#9ca3af" />
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{t('inspector.trackStats')}</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
<MapPin size={12} color="#3b82f6" />
{distKm < 1 ? `${Math.round(totalDist)} m` : `${distKm.toFixed(1)} km`}
</div>
{hasEle && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
<Mountain size={12} color="#22c55e" />
{Math.round(maxEle)} m
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
<Mountain size={12} color="#ef4444" />
{Math.round(minEle)} m
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{Math.round(totalUp)} m &nbsp;{Math.round(totalDown)} m
</div>
</>
)}
</div>
{pathD && (
<svg width="100%" viewBox={`0 0 ${chartW} ${chartH}`} preserveAspectRatio="none" style={{ display: 'block', borderRadius: 6, background: 'var(--bg-tertiary)' }}>
<defs>
<linearGradient id={`ele-grad-${place.id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.25" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0.02" />
</linearGradient>
</defs>
<path d={`${pathD} L${chartW},${chartH} L0,${chartH} Z`} fill={`url(#ele-grad-${place.id})`} />
<path d={pathD} fill="none" stroke="#3b82f6" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
</svg>
)}
</div>
)
} catch { return null }
})()}
{/* Files section */}
{(placeFiles.length > 0 || onFileUpload) && (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', padding: '8px 12px', gap: 6 }}>
<button
onClick={() => setFilesExpanded(f => !f)}
style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 6, background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit', textAlign: 'left' }}
>
<FileText size={13} color="#9ca3af" />
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>
{placeFiles.length > 0 ? t('inspector.filesCount', { count: placeFiles.length }) : t('inspector.files')}
</span>
{filesExpanded ? <ChevronUp size={12} color="#9ca3af" /> : <ChevronDown size={12} color="#9ca3af" />}
</button>
{onFileUpload && (
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-muted)', padding: '2px 6px', borderRadius: 6, background: 'var(--bg-tertiary)' }}>
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={handleFileUpload} />
{isUploading ? (
<span style={{ fontSize: 11 }}></span>
) : (
<><Upload size={11} strokeWidth={2} /> {t('common.upload')}</>
)}
</label>
)}
</div>
{filesExpanded && placeFiles.length > 0 && (
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{placeFiles.map(f => (
<button key={f.id} onClick={() => openFile(f.url).catch(() => {})} style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer', background: 'none', border: 'none', width: '100%', textAlign: 'left' }}>
{(f.mime_type || '').startsWith('image/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />}
<span style={{ fontSize: 12, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
{f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
</button>
))}
</div>
)}
</div>
)}
</div>
<PlaceExtras openingHours={openingHours} weekdayIndex={weekdayIndex} hoursExpanded={hoursExpanded}
setHoursExpanded={setHoursExpanded} timeFormat={timeFormat} t={t} place={place} placeFiles={placeFiles}
onFileUpload={onFileUpload} filesExpanded={filesExpanded} setFilesExpanded={setFilesExpanded}
fileInputRef={fileInputRef} handleFileUpload={handleFileUpload} isUploading={isUploading} />
</div>
@@ -831,3 +507,359 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip
</div>
)
}
function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameInputRef, nameValue, setNameValue,
commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, locale, timeFormat, onClose }: any) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: openNow !== null ? 26 : 14, padding: openNow !== null ? '18px 16px 14px 28px' : '18px 16px 14px', borderBottom: '1px solid var(--border-faint)' }}>
{/* Avatar with open/closed ring + tag */}
<div style={{ position: 'relative', flexShrink: 0, marginBottom: openNow !== null ? 8 : 0 }}>
<div style={{
borderRadius: '50%', padding: 2.5,
background: openNow === true ? '#22c55e' : openNow === false ? '#ef4444' : 'transparent',
}}>
<PlaceAvatar place={place} category={category} size={52} />
</div>
{openNow !== null && (
<span style={{
position: 'absolute', bottom: -7, left: '50%', transform: 'translateX(-50%)',
fontSize: 9, fontWeight: 500, letterSpacing: '0.02em',
color: 'white',
background: openNow ? '#16a34a' : '#dc2626',
padding: '1.5px 7px', borderRadius: 99,
whiteSpace: 'nowrap',
boxShadow: '0 1px 4px rgba(0,0,0,0.2)',
}}>
{openNow ? t('inspector.opened') : t('inspector.closed')}
</span>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
{editingName ? (
<input
ref={nameInputRef}
value={nameValue}
onChange={e => setNameValue(e.target.value)}
onBlur={commitNameEdit}
onKeyDown={handleNameKeyDown}
style={{ fontWeight: 600, fontSize: 15, color: 'var(--text-primary)', lineHeight: '1.3', background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, padding: '1px 6px', fontFamily: 'inherit', outline: 'none', width: '100%' }}
/>
) : (
<span
onDoubleClick={startNameEdit}
style={{ fontWeight: 600, fontSize: 15, color: 'var(--text-primary)', lineHeight: '1.3', cursor: onUpdatePlace ? 'text' : 'default' }}
>{place.name}</span>
)}
{category && (() => {
const CatIcon = getCategoryIcon(category.icon)
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: 11, fontWeight: 500,
color: category.color || '#6b7280',
background: category.color ? `${category.color}18` : 'rgba(0,0,0,0.06)',
border: `1px solid ${category.color ? `${category.color}30` : 'transparent'}`,
padding: '2px 8px', borderRadius: 99,
}}>
<CatIcon size={10} />
<span className="hidden sm:inline">{category.name}</span>
</span>
)
})()}
</div>
{place.address && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4, marginTop: 6 }}>
<MapPin size={11} color="var(--text-faint)" style={{ flexShrink: 0, marginTop: 2 }} />
<span style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.4', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{place.address}</span>
</div>
)}
{place.place_time && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 3 }}>
<Clock size={10} color="var(--text-faint)" style={{ flexShrink: 0 }} />
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{formatTime(place.place_time, locale, timeFormat)}{place.end_time ? ` ${formatTime(place.end_time, locale, timeFormat)}` : ''}</span>
</div>
)}
{place.lat && place.lng && (
<div className="hidden sm:block" style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 4, fontVariantNumeric: 'tabular-nums' }}>
{Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)}
</div>
)}
</div>
<button
onClick={onClose}
style={{ width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-hover)', border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0, alignSelf: 'flex-start', transition: 'background 0.15s' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-hover)'}
>
<X size={14} strokeWidth={2} color="var(--text-secondary)" />
</button>
</div>
)
}
function PlaceReservationParticipants({ selectedAssignmentId, reservations, assignments, selectedDayId,
tripMembers, locale, timeFormat, t, onSetParticipants }: any) {
return (
<>
{(() => {
const res = selectedAssignmentId ? reservations.find(r => r.assignment_id === selectedAssignmentId) : null
const assignment = selectedAssignmentId ? (assignments[String(selectedDayId)] || []).find(a => a.id === selectedAssignmentId) : null
const currentParticipants = assignment?.participants || []
const participantIds = currentParticipants.map(p => p.user_id)
const allJoined = currentParticipants.length === 0
const showParticipants = selectedAssignmentId && tripMembers.length > 1
if (!res && !showParticipants) return null
return (
<div className={`grid ${res && showParticipants ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'} gap-2`}>
{/* Reservation */}
{res && (() => {
const confirmed = res.status === 'confirmed'
return (
<div style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: confirmed ? 'rgba(22,163,74,0.08)' : 'rgba(217,119,6,0.08)' }}>
<div style={{ width: 6, height: 6, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
<span style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706' }}>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
<span style={{ flex: 1 }} />
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span>
</div>
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{(() => {
const { date, time: startTime } = splitReservationDateTime(res.reservation_time)
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
return (
<>
{date && (
<div>
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
</div>
)}
{(startTime || endTime) && (
<div>
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.time')}</div>
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
{startTime ? formatTime(startTime, locale, timeFormat) : ''}
{endTime ? ` ${formatTime(endTime, locale, timeFormat)}` : ''}
</div>
</div>
)}
</>
)
})()}
{res.confirmation_number && (
<div>
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{res.confirmation_number}</div>
</div>
)}
</div>
{res.notes && <div className="collab-note-md" style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4, wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{res.notes}</Markdown></div>}
{(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta || Object.keys(meta).length === 0) return null
const parts: string[] = []
if (meta.airline && meta.flight_number) parts.push(`${meta.airline} ${meta.flight_number}`)
else if (meta.flight_number) parts.push(meta.flight_number)
if (meta.departure_airport && meta.arrival_airport) parts.push(`${meta.departure_airport}${meta.arrival_airport}`)
if (meta.train_number) parts.push(meta.train_number)
if (meta.platform) parts.push(`Gl. ${meta.platform}`)
if (meta.check_in_time) parts.push(`Check-in ${meta.check_in_time}`)
if (meta.check_out_time) parts.push(`Check-out ${meta.check_out_time}`)
if (parts.length === 0) return null
return <div style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-muted)', fontWeight: 500 }}>{parts.join(' · ')}</div>
})()}
</div>
)
})()}
{/* Participants */}
{showParticipants && (
<ParticipantsBox
tripMembers={tripMembers}
participantIds={participantIds}
allJoined={allJoined}
onSetParticipants={onSetParticipants}
selectedAssignmentId={selectedAssignmentId}
selectedDayId={selectedDayId}
t={t}
/>
)}
</div>
)
})()}
</>
)
}
function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpanded, timeFormat, t, place,
placeFiles, onFileUpload, filesExpanded, setFilesExpanded, fileInputRef, handleFileUpload, isUploading }: any) {
return (
<div className={`grid grid-cols-1 ${openingHours?.length > 0 ? 'sm:grid-cols-2' : ''} gap-2`}>
{openingHours && openingHours.length > 0 && (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
<button
onClick={() => setHoursExpanded(h => !h)}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '8px 12px', background: 'none', border: 'none', cursor: 'pointer',
fontFamily: 'inherit',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Clock size={13} color="#9ca3af" />
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>
{hoursExpanded ? t('inspector.openingHours') : (convertHoursLine(openingHours[weekdayIndex] || '', timeFormat) || t('inspector.showHours'))}
</span>
</div>
{hoursExpanded ? <ChevronUp size={13} color="#9ca3af" /> : <ChevronDown size={13} color="#9ca3af" />}
</button>
{hoursExpanded && (
<div style={{ padding: '0 12px 10px' }}>
{openingHours.map((line, i) => (
<div key={i} style={{
fontSize: 12, color: i === weekdayIndex ? 'var(--text-primary)' : 'var(--text-muted)',
fontWeight: i === weekdayIndex ? 600 : 400,
padding: '2px 0',
}}>{convertHoursLine(line, timeFormat)}</div>
))}
</div>
)}
</div>
)}
{/* GPX Track stats */}
{place.route_geometry && (() => {
try {
const pts: number[][] = JSON.parse(place.route_geometry)
if (!pts || pts.length < 2) return null
const hasEle = pts[0].length >= 3
// Haversine distance
const toRad = (d: number) => d * Math.PI / 180
let totalDist = 0
for (let i = 1; i < pts.length; i++) {
const [lat1, lng1] = pts[i - 1], [lat2, lng2] = pts[i]
const dLat = toRad(lat2 - lat1), dLng = toRad(lng2 - lng1)
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2
totalDist += 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}
const distKm = totalDist / 1000
// Elevation stats
let minEle = Infinity, maxEle = -Infinity, totalUp = 0, totalDown = 0
if (hasEle) {
for (let i = 0; i < pts.length; i++) {
const e = pts[i][2]
if (e < minEle) minEle = e
if (e > maxEle) maxEle = e
if (i > 0) {
const diff = e - pts[i - 1][2]
if (diff > 0) totalUp += diff; else totalDown += Math.abs(diff)
}
}
}
// Elevation profile SVG
const chartW = 280, chartH = 60
const elevations = hasEle ? pts.map(p => p[2]) : []
let pathD = ''
if (elevations.length > 1) {
const step = Math.max(1, Math.floor(elevations.length / chartW))
const sampled = elevations.filter((_, i) => i % step === 0)
const eMin = Math.min(...sampled), eMax = Math.max(...sampled)
const range = eMax - eMin || 1
pathD = sampled.map((e, i) => {
const x = (i / (sampled.length - 1)) * chartW
const y = chartH - ((e - eMin) / range) * (chartH - 4) - 2
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
}).join(' ')
}
return (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<TrendingUp size={13} color="#9ca3af" />
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{t('inspector.trackStats')}</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
<MapPin size={12} color="#3b82f6" />
{distKm < 1 ? `${Math.round(totalDist)} m` : `${distKm.toFixed(1)} km`}
</div>
{hasEle && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
<Mountain size={12} color="#22c55e" />
{Math.round(maxEle)} m
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
<Mountain size={12} color="#ef4444" />
{Math.round(minEle)} m
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{Math.round(totalUp)} m &nbsp;{Math.round(totalDown)} m
</div>
</>
)}
</div>
{pathD && (
<svg width="100%" viewBox={`0 0 ${chartW} ${chartH}`} preserveAspectRatio="none" style={{ display: 'block', borderRadius: 6, background: 'var(--bg-tertiary)' }}>
<defs>
<linearGradient id={`ele-grad-${place.id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.25" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0.02" />
</linearGradient>
</defs>
<path d={`${pathD} L${chartW},${chartH} L0,${chartH} Z`} fill={`url(#ele-grad-${place.id})`} />
<path d={pathD} fill="none" stroke="#3b82f6" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
</svg>
)}
</div>
)
} catch { return null }
})()}
{/* Files section */}
{(placeFiles.length > 0 || onFileUpload) && (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', padding: '8px 12px', gap: 6 }}>
<button
onClick={() => setFilesExpanded(f => !f)}
style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 6, background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit', textAlign: 'left' }}
>
<FileText size={13} color="#9ca3af" />
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>
{placeFiles.length > 0 ? t('inspector.filesCount', { count: placeFiles.length }) : t('inspector.files')}
</span>
{filesExpanded ? <ChevronUp size={12} color="#9ca3af" /> : <ChevronDown size={12} color="#9ca3af" />}
</button>
{onFileUpload && (
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-muted)', padding: '2px 6px', borderRadius: 6, background: 'var(--bg-tertiary)' }}>
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={handleFileUpload} />
{isUploading ? (
<span style={{ fontSize: 11 }}></span>
) : (
<><Upload size={11} strokeWidth={2} /> {t('common.upload')}</>
)}
</label>
)}
</div>
{filesExpanded && placeFiles.length > 0 && (
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{placeFiles.map(f => (
<button key={f.id} onClick={() => openFile(f.url).catch(() => {})} style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer', background: 'none', border: 'none', width: '100%', textAlign: 'left' }}>
{(f.mime_type || '').startsWith('image/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />}
<span style={{ fontSize: 12, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
{f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
</button>
))}
</div>
)}
</div>
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,102 @@
import { useState, useEffect } from 'react'
import { weatherApi, accommodationsApi } from '../../api/client'
import { isDayInAccommodationRange } from '../../utils/dayOrder'
/** Day-detail data + accommodation logic: weather load, accommodations list,
* hotel picker form state and create/update/delete handlers. */
export function useDayDetail(day: any, days: any, tripId: any, lat: any, lng: any, language: any, onAccommodationChange: any) {
const [weather, setWeather] = useState(null)
const [loading, setLoading] = useState(false)
const [accommodation, setAccommodation] = useState(null)
const [dayAccommodations, setDayAccommodations] = useState<any[]>([])
const [accommodations, setAccommodations] = useState([])
const [showHotelPicker, setShowHotelPicker] = useState(false)
const [hotelDayRange, setHotelDayRange] = useState({ start: day?.id, end: day?.id })
const [hotelCategoryFilter, setHotelCategoryFilter] = useState('')
const [hotelForm, setHotelForm] = useState({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
useEffect(() => {
if (!day?.date || !lat || !lng) { setWeather(null); return }
setLoading(true)
weatherApi.getDetailed(lat, lng, day.date, language)
.then(data => setWeather(data.error ? null : data))
.catch(() => setWeather(null))
.finally(() => setLoading(false))
}, [day?.date, lat, lng, language])
useEffect(() => {
if (!tripId) return
accommodationsApi.list(tripId)
.then(data => {
setAccommodations(data.accommodations || [])
const allForDay = (data.accommodations || []).filter(a =>
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
)
setDayAccommodations(allForDay)
setAccommodation(allForDay[0] || null)
})
.catch(() => {})
}, [tripId, day?.id])
useEffect(() => { if (day) setHotelDayRange({ start: day.id, end: day.id }) }, [day?.id])
const handleSelectPlace = (placeId) => {
setHotelForm(f => ({ ...f, place_id: placeId }))
}
const handleSaveAccommodation = async () => {
if (!hotelForm.place_id) return
try {
const data = await accommodationsApi.create(tripId, {
place_id: hotelForm.place_id,
start_day_id: hotelDayRange.start,
end_day_id: hotelDayRange.end,
check_in: hotelForm.check_in || null,
check_in_end: hotelForm.check_in_end || null,
check_out: hotelForm.check_out || null,
confirmation: hotelForm.confirmation || null,
})
const newAcc = data.accommodation
const updated = [...accommodations, newAcc]
setAccommodations(updated)
setAccommodation(newAcc)
setDayAccommodations(updated.filter(a =>
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
))
setShowHotelPicker(false)
setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
onAccommodationChange?.()
} catch {}
}
const updateAccommodationField = async (field, value) => {
if (!accommodation) return
try {
const data = await accommodationsApi.update(tripId, accommodation.id, { [field]: value || null })
setAccommodation(data.accommodation)
onAccommodationChange?.()
} catch {}
}
const handleRemoveAccommodation = async () => {
if (!accommodation) return
try {
await accommodationsApi.delete(tripId, accommodation.id)
const updated = accommodations.filter(a => a.id !== accommodation.id)
setAccommodations(updated)
setDayAccommodations(updated.filter(a =>
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
))
setAccommodation(null)
onAccommodationChange?.()
} catch {}
}
return {
weather, loading, accommodation, setAccommodation, dayAccommodations, setDayAccommodations,
accommodations, setAccommodations, showHotelPicker, setShowHotelPicker,
hotelDayRange, setHotelDayRange, hotelCategoryFilter, setHotelCategoryFilter,
hotelForm, setHotelForm, handleSelectPlace, handleSaveAccommodation,
updateAccommodationField, handleRemoveAccommodation,
}
}
@@ -93,6 +93,18 @@ interface McpToken {
}
export default function IntegrationsTab(): React.ReactElement {
const S = useIntegrations()
return (
<>
<PhotoProvidersSection />
{S.mcpEnabled && <IntegrationsMcpSection {...S} />}
<McpTokenModals {...S} />
<OAuthClientModals {...S} />
</>
)
}
function useIntegrations() {
const { t, locale } = useTranslation()
const toast = useToast()
const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
@@ -275,10 +287,17 @@ export default function IntegrationsTab(): React.ReactElement {
}
}
return {
t, locale, toast, mcpEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
}
}
function IntegrationsMcpSection(props: any) {
const {
t, locale, toast, mcpEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
} = props
return (
<>
<PhotoProvidersSection />
{mcpEnabled && (
<Section title={t('settings.mcp.title')} icon={Terminal}>
{/* Endpoint URL */}
<div>
@@ -515,8 +534,15 @@ export default function IntegrationsTab(): React.ReactElement {
</>
)}
</Section>
)}
)
}
function McpTokenModals(props: any) {
const {
t, locale, toast, mcpEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
} = props
return (
<>
{/* Create MCP Token modal */}
{mcpModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
@@ -595,6 +621,16 @@ export default function IntegrationsTab(): React.ReactElement {
</div>
)}
</>
)
}
function OAuthClientModals(props: any) {
const {
t, locale, toast, mcpEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
} = props
return (
<>
{/* Create OAuth Client modal */}
{oauthCreateOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
@@ -303,7 +303,14 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
);
}
export function ModalRenderer({ notices }: Props) {
/**
* Drives the system-notice modal: pager index + visibility, mobile/dark/reduced-
* motion detection, body-scroll lock, keyboard (ESC + arrows) and the page-slide
* animation refs. Exposes dismiss/CTA/pager handlers + the touch-drag refs used
* by the mobile bottom sheet. The two layout components below render from it.
*/
function useSystemNoticeModal(notices: SystemNoticeDTO[]) {
const [idx, setIdx] = useState(0);
const [visible, setVisible] = useState(false);
const [pageAnnouncement, setPageAnnouncement] = useState('');
@@ -568,236 +575,231 @@ export function ModalRenderer({ notices }: Props) {
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,
return {
notices, idx, setIdx, visible, pageAnnouncement, isMobile, isDark, prefersReducedMotion,
notice, canPage, isLastPage, language, t, dur, ease,
touchStartX, touchStartY, dragLockRef, scrollTopAtTouchStart, isPageNavRef,
stripRef, sheetRef, prevSlotRef, contentWrapperRef, nextSlotRef,
announceIndex, handleDismiss, handleDismissAll, handleCTA, animatedDismissAll,
handlePrev, handleNext, handleGoto,
};
}
type NoticeState = ReturnType<typeof useSystemNoticeModal>;
// Build the NoticeContent props for a given notice + pager slot index.
function makeContentProps(S: NoticeState, n: SystemNoticeDTO, slotIdx: number): ContentProps {
const { t, isDark, canPage, notices, handleDismiss, handleDismissAll, handleCTA, handlePrev, handleNext, handleGoto } = S;
const rawBody = t(n.bodyKey);
const body = n.bodyParams
? Object.entries(n.bodyParams).reduce(
(s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), v),
rawBody
)
: rawBody;
return {
notice: n,
title: t(n.titleKey),
body,
ctaLabel: n.cta ? t(n.cta.labelKey) : null,
titleId: `notice-title-${n.id}`,
bodyId: `notice-body-${n.id}`,
isDark,
onDismiss: handleDismiss,
onDismissAll: handleDismissAll,
onCTA: handleCTA,
total: notices.length,
currentPage: idx,
currentPage: slotIdx,
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');
function MobileNoticeSheet(S: NoticeState) {
const {
notice, idx, notices, visible, dur, ease, prefersReducedMotion, pageAnnouncement,
language, canPage, setIdx, announceIndex, isPageNavRef, animatedDismissAll,
touchStartX, touchStartY, dragLockRef, scrollTopAtTouchStart,
stripRef, sheetRef, prevSlotRef, contentWrapperRef, nextSlotRef,
} = S;
if (!notice) return null;
const titleId = `notice-title-${notice.id}`;
const bodyId = `notice-body-${notice.id}`;
const mobileMotion = prefersReducedMotion
? (visible ? 'opacity-100' : 'opacity-0')
: (visible ? 'opacity-100 translate-y-0' : 'opacity-100 translate-y-full');
// Build ContentProps for an adjacent slot so NoticeContent renders correctly
function buildSlotProps(n: SystemNoticeDTO, slotIdx: number): ContentProps {
const slotRawBody = t(n.bodyKey);
const slotBody = n.bodyParams
? Object.entries(n.bodyParams).reduce(
(s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), v),
slotRawBody
)
: slotRawBody;
return {
notice: n,
title: t(n.titleKey),
body: slotBody,
ctaLabel: n.cta ? t(n.cta.labelKey) : null,
titleId: `notice-title-${n.id}`,
bodyId: `notice-body-${n.id}`,
isDark,
onDismiss: handleDismiss,
onDismissAll: handleDismissAll,
onCTA: handleCTA,
total: notices.length,
currentPage: slotIdx,
canPage,
onPrev: handlePrev,
onNext: handleNext,
onGoto: handleGoto,
};
}
const prevNotice = notices[idx - 1] ?? null;
const nextNotice = notices[idx + 1] ?? null;
const prevNotice = notices[idx - 1] ?? null;
const nextNotice = notices[idx + 1] ?? null;
return (
<div className="fixed inset-0 z-50" role="presentation">
{/* Screen-reader page announcements */}
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{pageAnnouncement}</span>
{/* Backdrop */}
<div
className={`absolute inset-0 bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
onClick={notice.dismissible ? animatedDismissAll : undefined}
/>
{/* Bottom sheet */}
<div
ref={sheetRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={bodyId}
className={`absolute bottom-0 left-0 right-0 rounded-t-3xl overflow-hidden h-[85dvh] flex flex-col bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl transition-[opacity,transform] ${dur} ${ease} ${mobileMotion}`}
style={{ touchAction: 'pan-y' }}
onTouchStart={e => {
touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY;
dragLockRef.current = null;
scrollTopAtTouchStart.current = contentWrapperRef.current?.scrollTop ?? 0;
}}
onTouchMove={e => {
if (prefersReducedMotion) return;
const startX = touchStartX.current;
const startY = touchStartY.current;
if (startX === null || startY === null) return;
const dx = e.touches[0].clientX - startX;
const dy = e.touches[0].clientY - startY;
// Classify gesture direction on first significant movement
if (!dragLockRef.current) {
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
dragLockRef.current = Math.abs(dx) >= Math.abs(dy) ? 'h' : 'v';
// Reset adjacent slots to top before they slide into view.
if (dragLockRef.current === 'h') {
prevSlotRef.current?.scrollTo({ top: 0 });
nextSlotRef.current?.scrollTo({ top: 0 });
}
return (
<div className="fixed inset-0 z-50" role="presentation">
{/* Screen-reader page announcements */}
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{pageAnnouncement}</span>
{/* Backdrop */}
<div
className={`absolute inset-0 bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
onClick={notice.dismissible ? animatedDismissAll : undefined}
/>
{/* Bottom sheet */}
<div
ref={sheetRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={bodyId}
className={`absolute bottom-0 left-0 right-0 rounded-t-3xl overflow-hidden h-[85dvh] flex flex-col bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl transition-[opacity,transform] ${dur} ${ease} ${mobileMotion}`}
style={{ touchAction: 'pan-y' }}
onTouchStart={e => {
touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY;
dragLockRef.current = null;
scrollTopAtTouchStart.current = contentWrapperRef.current?.scrollTop ?? 0;
}}
onTouchMove={e => {
if (prefersReducedMotion) return;
const startX = touchStartX.current;
const startY = touchStartY.current;
if (startX === null || startY === null) return;
const dx = e.touches[0].clientX - startX;
const dy = e.touches[0].clientY - startY;
// Classify gesture direction on first significant movement
if (!dragLockRef.current) {
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
dragLockRef.current = Math.abs(dx) >= Math.abs(dy) ? 'h' : 'v';
// Reset adjacent slots to top before they slide into view.
if (dragLockRef.current === 'h') {
prevSlotRef.current?.scrollTo({ top: 0 });
nextSlotRef.current?.scrollTo({ top: 0 });
}
return;
}
if (dragLockRef.current === 'h') {
const strip = stripRef.current;
if (!strip) return;
strip.style.transition = 'none';
// Strip base = -33.333% (center slot visible); dx offsets from there
strip.style.transform = `translateX(calc(-33.333% + ${dx}px))`;
} else if (dragLockRef.current === 'v' && notice.dismissible) {
// Only intercept downward drag for dismiss when the sheet is scrolled to the top.
// If scrolled into content, let native pan-y scroll it back up.
if (scrollTopAtTouchStart.current > 0) return;
const sheet = sheetRef.current;
if (!sheet || dy <= 0) return;
sheet.style.transition = 'none';
sheet.style.transform = `translateY(${dy}px)`;
}
}}
onTouchEnd={e => {
const startX = touchStartX.current;
const startY = touchStartY.current;
touchStartX.current = null;
touchStartY.current = null;
const lock = dragLockRef.current;
dragLockRef.current = null;
return;
}
if (dragLockRef.current === 'h') {
const strip = stripRef.current;
if (!strip) return;
strip.style.transition = 'none';
// Strip base = -33.333% (center slot visible); dx offsets from there
strip.style.transform = `translateX(calc(-33.333% + ${dx}px))`;
} else if (dragLockRef.current === 'v' && notice.dismissible) {
// Only intercept downward drag for dismiss when the sheet is scrolled to the top.
// If scrolled into content, let native pan-y scroll it back up.
if (scrollTopAtTouchStart.current > 0) return;
const sheet = sheetRef.current;
if (!sheet || dy <= 0) return;
sheet.style.transition = 'none';
sheet.style.transform = `translateY(${dy}px)`;
}
}}
onTouchEnd={e => {
const startX = touchStartX.current;
const startY = touchStartY.current;
touchStartX.current = null;
touchStartY.current = null;
const lock = dragLockRef.current;
dragLockRef.current = null;
if (lock === 'h') {
if (startX === null) return;
const deltaX = e.changedTouches[0].clientX - startX;
const strip = stripRef.current;
if (!strip) return;
if (lock === 'h') {
if (startX === null) return;
const deltaX = e.changedTouches[0].clientX - startX;
const strip = stripRef.current;
if (!strip) return;
const goNext = isRtlLanguage(language) ? deltaX > 50 : deltaX < -50;
const goPrev = isRtlLanguage(language) ? deltaX < -50 : deltaX > 50;
const canGoNext = canPage && idx < notices.length - 1;
const canGoPrev = canPage && idx > 0;
const goNext = isRtlLanguage(language) ? deltaX > 50 : deltaX < -50;
const goPrev = isRtlLanguage(language) ? deltaX < -50 : deltaX > 50;
const canGoNext = canPage && idx < notices.length - 1;
const canGoPrev = canPage && idx > 0;
if ((goNext && canGoNext) || (goPrev && canGoPrev)) {
// Animate strip to the adjacent slot (-66.666% = next, 0% = prev)
strip.style.transition = 'transform 200ms ease-out';
strip.style.transform = goNext ? 'translateX(-66.666%)' : 'translateX(0%)';
strip.addEventListener('transitionend', function onDone() {
strip.removeEventListener('transitionend', onDone);
strip.style.transition = 'none';
// Render new content into the center slot BEFORE moving the strip,
// so the browser never paints old content at the center position.
const newIdx = goNext ? idx + 1 : idx - 1;
flushSync(() => {
isPageNavRef.current = true;
setIdx(newIdx);
announceIndex(newIdx, notices.length);
});
// Reset all slot scrolls so the new center starts at top.
prevSlotRef.current?.scrollTo({ top: 0 });
contentWrapperRef.current?.scrollTo({ top: 0 });
nextSlotRef.current?.scrollTo({ top: 0 });
strip.style.transform = 'translateX(-33.333%)';
}, { once: true });
} else {
// Spring back to center
strip.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)';
if ((goNext && canGoNext) || (goPrev && canGoPrev)) {
// Animate strip to the adjacent slot (-66.666% = next, 0% = prev)
strip.style.transition = 'transform 200ms ease-out';
strip.style.transform = goNext ? 'translateX(-66.666%)' : 'translateX(0%)';
strip.addEventListener('transitionend', function onDone() {
strip.removeEventListener('transitionend', onDone);
strip.style.transition = 'none';
// Render new content into the center slot BEFORE moving the strip,
// so the browser never paints old content at the center position.
const newIdx = goNext ? idx + 1 : idx - 1;
flushSync(() => {
isPageNavRef.current = true;
setIdx(newIdx);
announceIndex(newIdx, notices.length);
});
// Reset all slot scrolls so the new center starts at top.
prevSlotRef.current?.scrollTo({ top: 0 });
contentWrapperRef.current?.scrollTo({ top: 0 });
nextSlotRef.current?.scrollTo({ top: 0 });
strip.style.transform = 'translateX(-33.333%)';
strip.addEventListener('transitionend', function onSnap() {
strip.removeEventListener('transitionend', onSnap);
strip.style.transition = '';
strip.style.transform = 'translateX(-33.333%)';
}, { once: true });
}
return;
}, { once: true });
} else {
// Spring back to center
strip.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)';
strip.style.transform = 'translateX(-33.333%)';
strip.addEventListener('transitionend', function onSnap() {
strip.removeEventListener('transitionend', onSnap);
strip.style.transition = '';
strip.style.transform = 'translateX(-33.333%)';
}, { once: true });
}
return;
}
// Vertical drag — animated dismiss or spring back (only when at scroll top)
if (lock === 'v' && startY !== null && scrollTopAtTouchStart.current === 0) {
const deltaY = e.changedTouches[0].clientY - startY;
const sheet = sheetRef.current;
if (deltaY > 80 && notice.dismissible) {
animatedDismissAll();
} else if (sheet && deltaY > 0) {
sheet.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)';
sheet.style.transform = 'translateY(0)';
sheet.addEventListener('transitionend', function onSnap() {
sheet.removeEventListener('transitionend', onSnap);
sheet.style.transition = '';
sheet.style.transform = '';
}, { once: true });
}
// Vertical drag — animated dismiss or spring back (only when at scroll top)
if (lock === 'v' && startY !== null && scrollTopAtTouchStart.current === 0) {
const deltaY = e.changedTouches[0].clientY - startY;
const sheet = sheetRef.current;
if (deltaY > 80 && notice.dismissible) {
animatedDismissAll();
} else if (sheet && deltaY > 0) {
sheet.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)';
sheet.style.transform = 'translateY(0)';
sheet.addEventListener('transitionend', function onSnap() {
sheet.removeEventListener('transitionend', onSnap);
sheet.style.transition = '';
sheet.style.transform = '';
}, { once: true });
}
}}
>
{/* Drag handle — fixed, does not scroll */}
<div className="pt-3 pb-1 flex justify-center shrink-0">
<div className="w-9 h-1 rounded-full bg-slate-300 dark:bg-slate-600" />
</div>
{/* Clip container — fills remaining sheet height, hides adjacent slots */}
<div style={{ flex: '1 1 0', minHeight: 0, overflow: 'hidden', width: '100%' }}>
{/* 3-slot strip: [prev][current][next] — starts at -33.333% to show current */}
<div
ref={stripRef}
style={{ display: 'flex', width: '300%', height: '100%', alignItems: 'stretch', transform: 'translateX(-33.333%)' }}
>
<div ref={prevSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{prevNotice && <NoticeContent {...buildSlotProps(prevNotice, idx - 1)} />}
</div>
<div ref={contentWrapperRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<NoticeContent {...contentProps} />
</div>
<div ref={nextSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{nextNotice && <NoticeContent {...buildSlotProps(nextNotice, idx + 1)} />}
</div>
}
}}
>
{/* Drag handle — fixed, does not scroll */}
<div className="pt-3 pb-1 flex justify-center shrink-0">
<div className="w-9 h-1 rounded-full bg-slate-300 dark:bg-slate-600" />
</div>
{/* Clip container — fills remaining sheet height, hides adjacent slots */}
<div style={{ flex: '1 1 0', minHeight: 0, overflow: 'hidden', width: '100%' }}>
{/* 3-slot strip: [prev][current][next] — starts at -33.333% to show current */}
<div
ref={stripRef}
style={{ display: 'flex', width: '300%', height: '100%', alignItems: 'stretch', transform: 'translateX(-33.333%)' }}
>
<div ref={prevSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{prevNotice && <NoticeContent {...makeContentProps(S, prevNotice, idx - 1)} />}
</div>
<div ref={contentWrapperRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<NoticeContent {...makeContentProps(S, notice, idx)} />
</div>
<div ref={nextSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{nextNotice && <NoticeContent {...makeContentProps(S, nextNotice, idx + 1)} />}
</div>
</div>
</div>
</div>
);
}
</div>
);
}
// Desktop centered modal
function DesktopNoticeModal(S: NoticeState) {
const { notice, idx, visible, dur, ease, prefersReducedMotion, pageAnnouncement, isLastPage, handleDismissAll, contentWrapperRef } = S;
if (!notice) return null;
const titleId = `notice-title-${notice.id}`;
const bodyId = `notice-body-${notice.id}`;
const maxWidth = notice.severity === 'critical' ? 'max-w-[680px]' : 'max-w-[620px]';
const desktopMotion = prefersReducedMotion
? (visible ? 'opacity-100' : 'opacity-0')
@@ -821,10 +823,17 @@ export function ModalRenderer({ notices }: Props) {
onClick={e => e.stopPropagation()}
>
<div ref={contentWrapperRef}>
<NoticeContent {...contentProps} />
<NoticeContent {...makeContentProps(S, notice, idx)} />
</div>
</div>
</div>
</div>
);
}
export function ModalRenderer({ notices }: Props) {
const S = useSystemNoticeModal(notices);
// No notice to show
if (!S.notice) return null;
return S.isMobile ? <MobileNoticeSheet {...S} /> : <DesktopNoticeModal {...S} />;
}
+27 -206
View File
@@ -15,111 +15,21 @@ import {
} from 'lucide-react'
import type { TodoItem } from '../../types'
const KAT_COLORS = [
'#3b82f6', '#a855f7', '#ec4899', '#22c55e', '#f97316',
'#06b6d4', '#ef4444', '#eab308', '#8b5cf6', '#14b8a6',
]
const PRIO_CONFIG: Record<number, { label: string; color: string }> = {
1: { label: 'P1', color: '#ef4444' },
2: { label: 'P2', color: '#f59e0b' },
3: { label: 'P3', color: '#3b82f6' },
}
function katColor(kat: string, allCategories: string[]) {
const idx = allCategories.indexOf(kat)
if (idx >= 0) return KAT_COLORS[idx % KAT_COLORS.length]
let h = 0
for (let i = 0; i < kat.length; i++) h = ((h << 5) - h + kat.charCodeAt(i)) | 0
return KAT_COLORS[Math.abs(h) % KAT_COLORS.length]
}
type FilterType = 'all' | 'my' | 'overdue' | 'done' | string
interface Member { id: number; username: string; avatar: string | null }
import { KAT_COLORS, PRIO_CONFIG, katColor, type FilterType, type Member } from './todoListModel'
import { useTodoList } from './useTodoList'
import TodoRow from './TodoRow'
export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tripId: number; items: TodoItem[]; addItemSignal?: number }) {
const { addTodoItem, updateTodoItem, deleteTodoItem, toggleTodoItem } = useTripStore()
const canEdit = useCanDo('packing_edit')
const toast = useToast()
const { t, locale } = useTranslation()
const formatDate = (d: string) => fmtDate(d, locale) || d
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768)
useEffect(() => {
const mq = window.matchMedia('(max-width: 767px)')
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
const [filter, setFilter] = useState<FilterType>('all')
const [selectedId, setSelectedId] = useState<number | null>(null)
const [isAddingNew, setIsAddingNew] = useState(false)
const lastHandledAddSignal = useRef(addItemSignal)
useEffect(() => {
if (addItemSignal !== lastHandledAddSignal.current && addItemSignal > 0) {
setSelectedId(null)
setIsAddingNew(true)
}
lastHandledAddSignal.current = addItemSignal
}, [addItemSignal])
const [sortByPrio, setSortByPrio] = useState(false)
const [addingCategory, setAddingCategory] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [members, setMembers] = useState<Member[]>([])
const [currentUserId, setCurrentUserId] = useState<number | null>(null)
useEffect(() => {
apiClient.get(`/trips/${tripId}/members`).then(r => {
const owner = r.data?.owner
const mems = r.data?.members || []
const all = owner ? [owner, ...mems] : mems
setMembers(all)
setCurrentUserId(r.data?.current_user_id || null)
}).catch(() => {})
}, [tripId])
const categories = useMemo(() => {
const cats = new Set<string>()
items.forEach(i => { if (i.category) cats.add(i.category) })
return Array.from(cats).sort()
}, [items])
const today = new Date().toISOString().split('T')[0]
const filtered = useMemo(() => {
let result: TodoItem[]
if (filter === 'all') result = items.filter(i => !i.checked)
else if (filter === 'done') result = items.filter(i => !!i.checked)
else if (filter === 'my') result = items.filter(i => !i.checked && i.assigned_user_id === currentUserId)
else if (filter === 'overdue') result = items.filter(i => !i.checked && i.due_date && i.due_date < today)
else result = items.filter(i => i.category === filter)
if (sortByPrio) result = [...result].sort((a, b) => {
const ap = a.priority || 99
const bp = b.priority || 99
return ap - bp
})
return result
}, [items, filter, currentUserId, today, sortByPrio])
const selectedItem = items.find(i => i.id === selectedId) || null
const totalCount = items.length
const doneCount = items.filter(i => !!i.checked).length
const overdueCount = items.filter(i => !i.checked && i.due_date && i.due_date < today).length
const myCount = currentUserId ? items.filter(i => !i.checked && i.assigned_user_id === currentUserId).length : 0
const addCategory = () => {
const name = newCategoryName.trim()
if (!name || categories.includes(name)) { setAddingCategory(false); setNewCategoryName(''); return }
addTodoItem(tripId, { name: t('todo.newItem'), category: name } as any)
.then(() => { setAddingCategory(false); setNewCategoryName(''); setFilter(name) })
.catch(err => toast.error(err instanceof Error ? err.message : t('common.error')))
}
// Get category count (non-done items)
const catCount = (cat: string) => items.filter(i => i.category === cat && !i.checked).length
// Layout component: state/effects/derived/handlers live in useTodoList.
const {
canEdit, t, formatDate, toggleTodoItem,
isMobile, filter, setFilter, selectedId, setSelectedId,
isAddingNew, setIsAddingNew, sortByPrio, setSortByPrio,
addingCategory, setAddingCategory, newCategoryName, setNewCategoryName,
members, categories, today, filtered, selectedItem,
totalCount, doneCount, overdueCount, myCount,
addCategory, catCount,
} = useTodoList(tripId, items, addItemSignal)
// Sidebar filter item
const SidebarItem = ({ id, icon: Icon, label, count, color }: { id: string; icon: any; label: string; count: number; color?: string }) => (
@@ -267,109 +177,20 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
{/* Task list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 0' }}>
{filtered.length === 0 ? null : (
filtered.map(item => {
const done = !!item.checked
const assignedUser = members.find(m => m.id === item.assigned_user_id)
const isOverdue = item.due_date && !done && item.due_date < today
const isSelected = selectedId === item.id
const catColor = item.category ? katColor(item.category, categories) : null
return (
<div key={item.id}
onClick={() => { setSelectedId(isSelected ? null : item.id); setIsAddingNew(false) }}
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 20px',
borderBottom: '1px solid var(--border-faint)', cursor: 'pointer',
background: isSelected ? 'var(--bg-hover)' : 'transparent',
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'rgba(0,0,0,0.02)' }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}>
{/* Checkbox */}
<button onClick={e => { e.stopPropagation(); canEdit && toggleTodoItem(tripId, item.id, !done) }}
style={{ background: 'none', border: 'none', cursor: canEdit ? 'pointer' : 'default', padding: 0, flexShrink: 0,
color: done ? '#22c55e' : 'var(--border-primary)' }}>
{done ? <CheckSquare size={18} /> : <Square size={18} />}
</button>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 14, color: done ? 'var(--text-faint)' : 'var(--text-primary)',
textDecoration: done ? 'line-through' : 'none', lineHeight: 1.4,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{item.name}
</div>
{/* Description preview */}
{item.description && (
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.4 }}>
{item.description}
</div>
)}
{/* Inline badges */}
{(item.priority || item.due_date || catColor || assignedUser) && (
<div style={{ display: 'flex', gap: 5, marginTop: 5, flexWrap: 'wrap' }}>
{item.priority > 0 && PRIO_CONFIG[item.priority] && (
<span style={{
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '2px 7px', borderRadius: 5, fontWeight: 600,
color: PRIO_CONFIG[item.priority].color,
background: `${PRIO_CONFIG[item.priority].color}10`,
border: `1px solid ${PRIO_CONFIG[item.priority].color}25`,
}}>
<Flag size={9} />{PRIO_CONFIG[item.priority].label}
</span>
)}
{item.due_date && (
<span style={{
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '2px 7px', borderRadius: 5, fontWeight: 500,
color: isOverdue ? '#ef4444' : 'var(--text-secondary)',
background: isOverdue ? 'rgba(239,68,68,0.08)' : 'var(--bg-hover)',
border: `1px solid ${isOverdue ? 'rgba(239,68,68,0.15)' : 'var(--border-faint)'}`,
}}>
<Calendar size={9} />{formatDate(item.due_date)}
</span>
)}
{catColor && (
<span style={{
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 7px', borderRadius: 5, fontWeight: 500,
color: 'var(--text-secondary)', background: 'var(--bg-hover)',
border: '1px solid var(--border-faint)',
}}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: catColor, flexShrink: 0 }} />
{item.category}
</span>
)}
{assignedUser && (
<span style={{
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 7px', borderRadius: 5, fontWeight: 500,
color: 'var(--text-secondary)', background: 'var(--bg-hover)',
border: '1px solid var(--border-faint)',
}}>
{assignedUser.avatar ? (
<img src={`/uploads/avatars/${assignedUser.avatar}`} style={{ width: 13, height: 13, borderRadius: '50%', objectFit: 'cover' }} alt="" />
) : (
<span style={{ width: 13, height: 13, borderRadius: '50%', background: 'var(--border-primary)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 7, color: 'var(--text-faint)', fontWeight: 700 }}>
{assignedUser.username.charAt(0).toUpperCase()}
</span>
)}
{assignedUser.username}
</span>
)}
</div>
)}
</div>
{/* Chevron */}
<ChevronRight size={16} color="var(--text-faint)" style={{ flexShrink: 0, opacity: 0.4 }} />
</div>
)
})
filtered.map(item => (
<TodoRow
key={item.id}
item={item}
members={members}
categories={categories}
today={today}
isSelected={selectedId === item.id}
canEdit={canEdit}
formatDate={formatDate}
onSelect={(id) => { setSelectedId(id); setIsAddingNew(false) }}
onToggle={(id, checked) => toggleTodoItem(tripId, id, checked)}
/>
))
)}
</div>
</div>
+118
View File
@@ -0,0 +1,118 @@
import { CheckSquare, Square, ChevronRight, Flag, Calendar } from 'lucide-react'
import type { TodoItem } from '../../types'
import { katColor, PRIO_CONFIG, type Member } from './todoListModel'
/** A single task row in the todo list. Pure presentation; all behaviour is
* delegated to onSelect/onToggle so TodoListPanel stays a layout component. */
export default function TodoRow({ item, members, categories, today, isSelected, canEdit, formatDate, onSelect, onToggle }: {
item: TodoItem
members: Member[]
categories: string[]
today: string
isSelected: boolean
canEdit: boolean
formatDate: (d: string) => string
onSelect: (id: number | null) => void
onToggle: (id: number, checked: boolean) => void
}) {
const done = !!item.checked
const assignedUser = members.find(m => m.id === item.assigned_user_id)
const isOverdue = item.due_date && !done && item.due_date < today
const catColor = item.category ? katColor(item.category, categories) : null
return (
<div key={item.id}
onClick={() => onSelect(isSelected ? null : item.id)}
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 20px',
borderBottom: '1px solid var(--border-faint)', cursor: 'pointer',
background: isSelected ? 'var(--bg-hover)' : 'transparent',
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'rgba(0,0,0,0.02)' }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}>
{/* Checkbox */}
<button onClick={e => { e.stopPropagation(); if (canEdit) onToggle(item.id, !done) }}
style={{ background: 'none', border: 'none', cursor: canEdit ? 'pointer' : 'default', padding: 0, flexShrink: 0,
color: done ? '#22c55e' : 'var(--border-primary)' }}>
{done ? <CheckSquare size={18} /> : <Square size={18} />}
</button>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 14, color: done ? 'var(--text-faint)' : 'var(--text-primary)',
textDecoration: done ? 'line-through' : 'none', lineHeight: 1.4,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{item.name}
</div>
{/* Description preview */}
{item.description && (
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.4 }}>
{item.description}
</div>
)}
{/* Inline badges */}
{(item.priority || item.due_date || catColor || assignedUser) && (
<div style={{ display: 'flex', gap: 5, marginTop: 5, flexWrap: 'wrap' }}>
{item.priority > 0 && PRIO_CONFIG[item.priority] && (
<span style={{
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '2px 7px', borderRadius: 5, fontWeight: 600,
color: PRIO_CONFIG[item.priority].color,
background: `${PRIO_CONFIG[item.priority].color}10`,
border: `1px solid ${PRIO_CONFIG[item.priority].color}25`,
}}>
<Flag size={9} />{PRIO_CONFIG[item.priority].label}
</span>
)}
{item.due_date && (
<span style={{
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '2px 7px', borderRadius: 5, fontWeight: 500,
color: isOverdue ? '#ef4444' : 'var(--text-secondary)',
background: isOverdue ? 'rgba(239,68,68,0.08)' : 'var(--bg-hover)',
border: `1px solid ${isOverdue ? 'rgba(239,68,68,0.15)' : 'var(--border-faint)'}`,
}}>
<Calendar size={9} />{formatDate(item.due_date)}
</span>
)}
{catColor && (
<span style={{
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 7px', borderRadius: 5, fontWeight: 500,
color: 'var(--text-secondary)', background: 'var(--bg-hover)',
border: '1px solid var(--border-faint)',
}}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: catColor, flexShrink: 0 }} />
{item.category}
</span>
)}
{assignedUser && (
<span style={{
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 7px', borderRadius: 5, fontWeight: 500,
color: 'var(--text-secondary)', background: 'var(--bg-hover)',
border: '1px solid var(--border-faint)',
}}>
{assignedUser.avatar ? (
<img src={`/uploads/avatars/${assignedUser.avatar}`} style={{ width: 13, height: 13, borderRadius: '50%', objectFit: 'cover' }} alt="" />
) : (
<span style={{ width: 13, height: 13, borderRadius: '50%', background: 'var(--border-primary)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 7, color: 'var(--text-faint)', fontWeight: 700 }}>
{assignedUser.username.charAt(0).toUpperCase()}
</span>
)}
{assignedUser.username}
</span>
)}
</div>
)}
</div>
{/* Chevron */}
<ChevronRight size={16} color="var(--text-faint)" style={{ flexShrink: 0, opacity: 0.4 }} />
</div>
)
}
@@ -0,0 +1,24 @@
// Pure constants + helpers + types for the todo list. No React, no side effects.
export const KAT_COLORS = [
'#3b82f6', '#a855f7', '#ec4899', '#22c55e', '#f97316',
'#06b6d4', '#ef4444', '#eab308', '#8b5cf6', '#14b8a6',
]
export const PRIO_CONFIG: Record<number, { label: string; color: string }> = {
1: { label: 'P1', color: '#ef4444' },
2: { label: 'P2', color: '#f59e0b' },
3: { label: 'P3', color: '#3b82f6' },
}
export function katColor(kat: string, allCategories: string[]) {
const idx = allCategories.indexOf(kat)
if (idx >= 0) return KAT_COLORS[idx % KAT_COLORS.length]
let h = 0
for (let i = 0; i < kat.length; i++) h = ((h << 5) - h + kat.charCodeAt(i)) | 0
return KAT_COLORS[Math.abs(h) % KAT_COLORS.length]
}
export type FilterType = 'all' | 'my' | 'overdue' | 'done' | string
export interface Member { id: number; username: string; avatar: string | null }
+111
View File
@@ -0,0 +1,111 @@
import { useState, useMemo, useEffect, useRef } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import apiClient from '../../api/client'
import { formatDate as fmtDate } from '../../utils/formatters'
import type { TodoItem } from '../../types'
import type { FilterType, Member } from './todoListModel'
/**
* Todo list logic store actions, member load, the filter/selection/add-new
* view state and the derived buckets (filtered list + counts) + handlers.
* TodoListPanel stays a layout component that renders the sidebar, the rows
* (TodoRow) and the detail/new panes from this state.
*/
export function useTodoList(tripId: number, items: TodoItem[], addItemSignal: number) {
const { addTodoItem, updateTodoItem, deleteTodoItem, toggleTodoItem } = useTripStore()
const canEdit = useCanDo('packing_edit')
const toast = useToast()
const { t, locale } = useTranslation()
const formatDate = (d: string) => fmtDate(d, locale) || d
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768)
useEffect(() => {
const mq = window.matchMedia('(max-width: 767px)')
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
const [filter, setFilter] = useState<FilterType>('all')
const [selectedId, setSelectedId] = useState<number | null>(null)
const [isAddingNew, setIsAddingNew] = useState(false)
const lastHandledAddSignal = useRef(addItemSignal)
useEffect(() => {
if (addItemSignal !== lastHandledAddSignal.current && addItemSignal > 0) {
setSelectedId(null)
setIsAddingNew(true)
}
lastHandledAddSignal.current = addItemSignal
}, [addItemSignal])
const [sortByPrio, setSortByPrio] = useState(false)
const [addingCategory, setAddingCategory] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [members, setMembers] = useState<Member[]>([])
const [currentUserId, setCurrentUserId] = useState<number | null>(null)
useEffect(() => {
apiClient.get(`/trips/${tripId}/members`).then(r => {
const owner = r.data?.owner
const mems = r.data?.members || []
const all = owner ? [owner, ...mems] : mems
setMembers(all)
setCurrentUserId(r.data?.current_user_id || null)
}).catch(() => {})
}, [tripId])
const categories = useMemo(() => {
const cats = new Set<string>()
items.forEach(i => { if (i.category) cats.add(i.category) })
return Array.from(cats).sort()
}, [items])
const today = new Date().toISOString().split('T')[0]
const filtered = useMemo(() => {
let result: TodoItem[]
if (filter === 'all') result = items.filter(i => !i.checked)
else if (filter === 'done') result = items.filter(i => !!i.checked)
else if (filter === 'my') result = items.filter(i => !i.checked && i.assigned_user_id === currentUserId)
else if (filter === 'overdue') result = items.filter(i => !i.checked && i.due_date && i.due_date < today)
else result = items.filter(i => i.category === filter)
if (sortByPrio) result = [...result].sort((a, b) => {
const ap = a.priority || 99
const bp = b.priority || 99
return ap - bp
})
return result
}, [items, filter, currentUserId, today, sortByPrio])
const selectedItem = items.find(i => i.id === selectedId) || null
const totalCount = items.length
const doneCount = items.filter(i => !!i.checked).length
const overdueCount = items.filter(i => !i.checked && i.due_date && i.due_date < today).length
const myCount = currentUserId ? items.filter(i => !i.checked && i.assigned_user_id === currentUserId).length : 0
const addCategory = () => {
const name = newCategoryName.trim()
if (!name || categories.includes(name)) { setAddingCategory(false); setNewCategoryName(''); return }
addTodoItem(tripId, { name: t('todo.newItem'), category: name } as any)
.then(() => { setAddingCategory(false); setNewCategoryName(''); setFilter(name) })
.catch(err => toast.error(err instanceof Error ? err.message : t('common.error')))
}
// Get category count (non-done items)
const catCount = (cat: string) => items.filter(i => i.category === cat && !i.checked).length
return {
canEdit, t, formatDate, toggleTodoItem,
isMobile, filter, setFilter, selectedId, setSelectedId,
isAddingNew, setIsAddingNew, sortByPrio, setSortByPrio,
addingCategory, setAddingCategory, newCategoryName, setNewCategoryName,
members, categories, today, filtered, selectedItem,
totalCount, doneCount, overdueCount, myCount,
addCategory, catCount,
// exposed for completeness (DetailPane/NewTaskPane already get their own)
updateTodoItem, deleteTodoItem,
}
}
+33
View File
@@ -0,0 +1,33 @@
import React from 'react'
interface SpinnerProps {
/**
* Tailwind classes controlling size + colours of the ring. Callers keep full
* control so adopting the component never changes a spinner's appearance.
* Defaults to the app's most common page-loader ring.
*/
className?: string
}
/** The bare spinning ring used throughout the app (`rounded-full animate-spin`). */
export function Spinner({ className = 'w-6 h-6 border-2 border-zinc-300 border-t-zinc-900' }: SpinnerProps): React.ReactElement {
return <div className={`${className} rounded-full animate-spin`} />
}
interface PageSpinnerProps extends SpinnerProps {
/** Wrapper classes for the centring container. */
wrapperClassName?: string
wrapperStyle?: React.CSSProperties
}
/**
* A full-area centred loading spinner the repeated "flex items-center
* justify-center" loader that page loading-guards render while data resolves.
*/
export function PageSpinner({ className, wrapperClassName = 'flex items-center justify-center', wrapperStyle }: PageSpinnerProps): React.ReactElement {
return (
<div className={wrapperClassName} style={wrapperStyle}>
<Spinner className={className} />
</div>
)
}
+58
View File
@@ -0,0 +1,58 @@
import type { JSX } from 'react'
import { useTranslation } from './TranslationContext'
interface TransHtmlProps<T extends keyof JSX.IntrinsicElements = 'span'> {
/**
* Translation key whose template legitimately contains markup (e.g.
* `'Turn <strong>{title}</strong> into a Journey'`).
*/
html: string
/**
* Values to interpolate into `{paramName}` placeholders. Every value is
* HTML-escaped before substitution, so passing user-controlled data is safe.
*/
params?: Record<string, string | number>
/**
* Element to render. Defaults to `<span>`. Use the tag that fits the
* surrounding flow block, inline, list item, etc.
*/
as?: T
className?: string
/**
* `id` is forwarded so the component can be the target of `aria-labelledby`
* or `htmlFor`. Other ARIA attributes can be added if needed; we intentionally
* keep the surface small to discourage overloading this with arbitrary props.
*/
id?: string
}
/**
* Renders a translation that contains markup (e.g. `<strong>`) safely.
*
* Replaces the pattern that bit us in the Journey suggestion banner:
* <span dangerouslySetInnerHTML={{ __html: t('...', { user_input }) }} />
*
* That pattern interpolates `user_input` into the template *before* React
* ever sees it, so a trip title like `<script>alert(1)</script>` would inject
* a script tag. `TransHtml` runs `tHtml()` which:
*
* 1. HTML-escapes every interpolated value, neutralising it.
* 2. Sanitises the resulting string against an inline tag allow-list.
*
* Use this for any user-controlled value that lands in a markup template.
* Plain text-only templates can continue to use `<>{t('key', params)}</>`.
*/
export function TransHtml<T extends keyof JSX.IntrinsicElements = 'span'>({
html,
params,
as,
className,
id,
}: TransHtmlProps<T>) {
const { tHtml } = useTranslation()
const Tag = (as ?? 'span') as keyof JSX.IntrinsicElements
return (
// eslint-disable-next-line react/no-danger -- sanitised by tHtml (defence in depth)
<Tag className={className} id={id} dangerouslySetInnerHTML={{ __html: tHtml(html, params) }} />
)
}
+40 -2
View File
@@ -7,6 +7,8 @@ import {
getLocaleForLanguage,
getIntlLanguage,
isRtlLanguage,
escapeHtml,
sanitizeInlineHtml,
} from '@trek/shared'
import type { TranslationStrings } from '@trek/shared/i18n'
@@ -68,11 +70,32 @@ export function detectBrowserLanguage(): string | null {
interface TranslationContextValue {
t: (key: string, params?: Record<string, string | number>) => string
/**
* HTML-aware variant of `t()`. Use ONLY when the translated template
* legitimately contains markup (e.g. `'Turn <strong>{title}</strong> into a Journey'`).
*
* Defence in depth, two layers:
* 1. Every interpolated param is HTML-escaped before substitution, so a
* user-controlled value like `<script>` cannot inject markup at all.
* 2. The fully-substituted string is then passed through
* `sanitizeInlineHtml`, so even if a translator ships a malformed
* template the runtime output is still tag-restricted.
*
* Prefer the `<TransHtml>` component for the typical "translate + render"
* pattern; reach for `tHtml()` directly only when you need the raw string
* (e.g. constructing an `aria-label`).
*/
tHtml: (key: string, params?: Record<string, string | number>) => string
language: string
locale: string
}
const TranslationContext = createContext<TranslationContextValue>({ t: (k: string) => k, language: 'en', locale: 'en-US' })
const TranslationContext = createContext<TranslationContextValue>({
t: (k: string) => k,
tHtml: (k: string) => k,
language: 'en',
locale: 'en-US',
})
interface TranslationProviderProps {
children: ReactNode
@@ -109,7 +132,22 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
return val
}
return { t, language, locale: getLocaleForLanguage(language) }
function tHtml(key: string, params?: Record<string, string | number>): string {
let val: string = (strings[key] ?? en[key] ?? key) as string
if (params) {
Object.entries(params).forEach(([k, v]) => {
// Escape BEFORE substitution so a user-controlled value with `<` or
// `&` cannot break out of the surrounding template's markup.
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), escapeHtml(String(v)))
})
}
// Then re-sanitise the fully-built string: even if a translator ships a
// template with stray `<script>` or `onclick`, the rendered output is
// restricted to the inline tag allow-list.
return sanitizeInlineHtml(val)
}
return { t, tHtml, language, locale: getLocaleForLanguage(language) }
}, [strings, language])
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>
+1
View File
@@ -7,3 +7,4 @@ export {
detectBrowserLanguage,
SUPPORTED_LANGUAGES,
} from './TranslationContext'
export { TransHtml } from './TransHtml'
+35 -346
View File
@@ -8,7 +8,7 @@ import { useSettingsStore } from '../store/settingsStore'
import { useAddonStore } from '../store/addonStore'
import { useTranslation } from '../i18n'
import { getApiErrorMessage } from '../types'
import Navbar from '../components/Layout/Navbar'
import PageShell from '../components/Layout/PageShell'
import Modal from '../components/shared/Modal'
import { useToast } from '../components/shared/Toast'
import { useCountUp } from '../hooks/useCountUp'
@@ -23,43 +23,7 @@ import PermissionsPanel from '../components/Admin/PermissionsPanel'
import { Users, Map, Briefcase, Shield, Trash2, Edit2, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle, SlidersHorizontal, UserCog, Puzzle, Settings as SettingsIcon, Bell, Database, ScrollText, KeyRound, GitBranch, Bug } from 'lucide-react'
import CustomSelect from '../components/shared/CustomSelect'
import PageSidebar, { type PageSidebarTab } from '../components/Layout/PageSidebar'
interface AdminUser {
id: number
username: string
email: string
role: 'admin' | 'user'
created_at: string
last_login?: string | null
online?: boolean
oidc_issuer?: string | null
avatar_url?: string | null
}
interface AdminStats {
totalUsers: number
totalTrips: number
totalPlaces: number
totalFiles: number
}
interface OidcConfig {
issuer: string
client_id: string
client_secret: string
client_secret_set: boolean
display_name: string
discovery_url: string
}
interface UpdateInfo {
update_available: boolean
latest: string
current: string
release_url?: string
is_docker?: boolean
is_prerelease?: boolean
}
import { useAdmin } from './admin/useAdmin'
const ADMIN_EVENT_LABEL_KEYS: Record<string, string> = {
version_available: 'settings.notifyVersionAvailable',
@@ -179,11 +143,38 @@ function AdminStatCard({ label, value, icon: Icon }: { label: string; value: num
}
export default function AdminPage(): React.ReactElement {
const { demoMode, serverTimezone } = useAuthStore()
const { t, locale } = useTranslation()
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
const mcpEnabled = useAddonStore(s => s.isEnabled('mcp'))
const devMode = useAuthStore(s => s.devMode)
// Page = wiring container: all admin data slices + handlers live in the hook.
const {
demoMode, serverTimezone, hour12, mcpEnabled, devMode, currentUser,
updateApiKeys, setAppRequireMfa, setTripRemindersEnabled,
setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, logout,
navigate, toast,
activeTab, setActiveTab, users, setUsers, stats, isLoading,
editingUser, setEditingUser, editForm, setEditForm,
showCreateUser, setShowCreateUser, createForm, setCreateForm,
bagTrackingEnabled, setBagTrackingEnabled,
placesPhotosEnabled, setPlacesPhotosEnabledState,
placesAutocompleteEnabled, setPlacesAutocompleteEnabledState,
placesDetailsEnabled, setPlacesDetailsEnabledState,
collabFeatures, setCollabFeatures,
oidcConfig, setOidcConfig, savingOidc, setSavingOidc,
passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration,
oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration,
envOverrideOidcOnly, setEnvOverrideOidcOnly, oidcConfigured, setOidcConfigured,
requireMfa, setRequireMfa,
invites, setInvites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
smtpValues, setSmtpValues, smtpLoaded,
mapsKey, setMapsKey, weatherKey, setWeatherKey,
showKeys, setShowKeys, savingKeys, validating, validation,
updateInfo, setUpdateInfo, showUpdateModal, setShowUpdateModal,
showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt,
loadData, loadAppConfig, loadApiKeys, handleToggleAuthSetting, handleToggleRequireMfa,
toggleKey, handleSaveApiKeys, handleValidateKeys, handleValidateKey,
handleCreateUser, handleCreateInvite, handleDeleteInvite, copyInviteLink,
handleEditUser, handleSaveUser, handleDeleteUser,
} = useAdmin()
const TABS: PageSidebarTab[] = [
{ id: 'users', label: t('admin.tabs.users'), icon: Users },
{ id: 'config', label: t('admin.tabs.config'), icon: SlidersHorizontal },
@@ -198,309 +189,8 @@ export default function AdminPage(): React.ReactElement {
...(devMode ? [{ id: 'dev-notifications', label: 'Dev: Notifications', icon: Bug }] : []),
]
const [activeTab, setActiveTab] = useState<string>('users')
const [users, setUsers] = useState<AdminUser[]>([])
const [stats, setStats] = useState<AdminStats | null>(null)
const [isLoading, setIsLoading] = useState<boolean>(true)
const [editingUser, setEditingUser] = useState<AdminUser | null>(null)
const [editForm, setEditForm] = useState<{ username: string; email: string; role: string; password: string }>({ username: '', email: '', role: 'user', password: '' })
const [showCreateUser, setShowCreateUser] = useState<boolean>(false)
const [createForm, setCreateForm] = useState<{ username: string; email: string; password: string; role: string }>({ username: '', email: '', password: '', role: 'user' })
// Bag tracking
const [bagTrackingEnabled, setBagTrackingEnabled] = useState<boolean>(false)
useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, [])
// Places photos
const [placesPhotosEnabled, setPlacesPhotosEnabledState] = useState<boolean>(true)
useEffect(() => { adminApi.getPlacesPhotos().then(d => setPlacesPhotosEnabledState(d.enabled)).catch(() => {}) }, [])
// Places autocomplete
const [placesAutocompleteEnabled, setPlacesAutocompleteEnabledState] = useState<boolean>(true)
useEffect(() => { adminApi.getPlacesAutocomplete().then(d => setPlacesAutocompleteEnabledState(d.enabled)).catch(() => {}) }, [])
// Places details
const [placesDetailsEnabled, setPlacesDetailsEnabledState] = useState<boolean>(true)
useEffect(() => { adminApi.getPlacesDetails().then(d => setPlacesDetailsEnabledState(d.enabled)).catch(() => {}) }, [])
// Collab features
const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, polls: true, whatsnext: true })
useEffect(() => { adminApi.getCollabFeatures().then(d => setCollabFeatures(d)).catch(() => {}) }, [])
// OIDC config
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', discovery_url: '' })
const [savingOidc, setSavingOidc] = useState<boolean>(false)
// Auth toggles
const [passwordLogin, setPasswordLogin] = useState<boolean>(true)
const [passwordRegistration, setPasswordRegistration] = useState<boolean>(true)
const [oidcLogin, setOidcLogin] = useState<boolean>(true)
const [oidcRegistration, setOidcRegistration] = useState<boolean>(true)
const [envOverrideOidcOnly, setEnvOverrideOidcOnly] = useState<boolean>(false)
const [oidcConfigured, setOidcConfigured] = useState<boolean>(false)
const [requireMfa, setRequireMfa] = useState<boolean>(false)
// Invite links
const [invites, setInvites] = useState<any[]>([])
const [showCreateInvite, setShowCreateInvite] = useState<boolean>(false)
const [inviteForm, setInviteForm] = useState<{ max_uses: number; expires_in_days: number | '' }>({ max_uses: 1, expires_in_days: 7 })
// File types
const [allowedFileTypes, setAllowedFileTypes] = useState<string>('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv')
const [savingFileTypes, setSavingFileTypes] = useState<boolean>(false)
// SMTP settings
const [smtpValues, setSmtpValues] = useState<Record<string, string>>({})
const [smtpLoaded, setSmtpLoaded] = useState(false)
useEffect(() => {
apiClient.get('/auth/app-settings').then(r => {
setSmtpValues(r.data || {})
setSmtpLoaded(true)
}).catch(() => setSmtpLoaded(true))
}, [])
// API Keys
const [mapsKey, setMapsKey] = useState<string>('')
const [weatherKey, setWeatherKey] = useState<string>('')
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({})
const [savingKeys, setSavingKeys] = useState<boolean>(false)
const [validating, setValidating] = useState<Record<string, boolean>>({})
const [validation, setValidation] = useState<Record<string, boolean | undefined>>({})
// Version check & update
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false)
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, logout } = useAuthStore()
const navigate = useNavigate()
const toast = useToast()
const [showRotateJwtModal, setShowRotateJwtModal] = useState<boolean>(false)
const [rotatingJwt, setRotatingJwt] = useState<boolean>(false)
useEffect(() => {
loadData()
loadAppConfig()
loadApiKeys()
adminApi.getOidc().then(setOidcConfig).catch(() => {})
adminApi.checkVersion().then(data => {
if (data.update_available) setUpdateInfo(data)
}).catch(() => {})
}, [])
const loadData = async () => {
setIsLoading(true)
try {
const [usersData, statsData, invitesData] = await Promise.all([
adminApi.users(),
adminApi.stats(),
adminApi.listInvites().catch(() => ({ invites: [] })),
])
setUsers(usersData.users)
setStats(statsData)
setInvites(invitesData.invites || [])
} catch (err: unknown) {
toast.error(t('admin.toast.loadError'))
} finally {
setIsLoading(false)
}
}
const loadAppConfig = async () => {
try {
const config = await authApi.getAppConfig()
setPasswordLogin(config.password_login ?? true)
setPasswordRegistration(config.password_registration ?? config.allow_registration ?? true)
setOidcLogin(config.oidc_login ?? true)
setOidcRegistration(config.oidc_registration ?? config.allow_registration ?? true)
setEnvOverrideOidcOnly(config.env_override_oidc_only ?? false)
setOidcConfigured(config.oidc_configured ?? false)
if (config.require_mfa !== undefined) setRequireMfa(!!config.require_mfa)
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
} catch (err: unknown) {
// ignore
}
}
const loadApiKeys = async () => {
try {
const data = await authApi.getSettings()
setMapsKey(data.settings?.maps_api_key || '')
setWeatherKey(data.settings?.openweather_api_key || '')
} catch (err: unknown) {
// ignore
}
}
const handleToggleAuthSetting = async (key: string, value: boolean, setter: (v: boolean) => void) => {
setter(value)
try {
await authApi.updateAppSettings({ [key]: value })
} catch (err: unknown) {
setter(!value)
toast.error(getApiErrorMessage(err, t('common.error')))
}
}
const handleToggleRequireMfa = async (value: boolean) => {
setRequireMfa(value)
try {
await authApi.updateAppSettings({ require_mfa: value })
setAppRequireMfa(value)
toast.success(t('common.saved'))
} catch (err: unknown) {
setRequireMfa(!value)
toast.error(getApiErrorMessage(err, t('common.error')))
}
}
const toggleKey = (key) => {
setShowKeys(prev => ({ ...prev, [key]: !prev[key] }))
}
const handleSaveApiKeys = async () => {
setSavingKeys(true)
try {
await updateApiKeys({
maps_api_key: mapsKey,
openweather_api_key: weatherKey,
})
toast.success(t('admin.keySaved'))
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : 'Unknown error')
} finally {
setSavingKeys(false)
}
}
const handleValidateKeys = async () => {
setValidating({ maps: true, weather: true })
try {
// Save first so validation uses the current values
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey })
const result = await authApi.validateKeys()
setValidation(result)
} catch (err: unknown) {
toast.error(t('common.error'))
} finally {
setValidating({})
}
}
const handleValidateKey = async (keyType) => {
setValidating(prev => ({ ...prev, [keyType]: true }))
try {
// Save first so validation uses the current values
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey })
const result = await authApi.validateKeys()
setValidation(prev => ({ ...prev, [keyType]: result[keyType] }))
} catch (err: unknown) {
toast.error(t('common.error'))
} finally {
setValidating(prev => ({ ...prev, [keyType]: false }))
}
}
const handleCreateUser = async () => {
if (!createForm.username.trim() || !createForm.email.trim() || !createForm.password.trim()) {
toast.error(t('admin.toast.fieldsRequired'))
return
}
if (createForm.password.trim().length < 8) {
toast.error(t('settings.passwordTooShort'))
return
}
try {
const data = await adminApi.createUser(createForm)
setUsers(prev => [data.user, ...prev])
setShowCreateUser(false)
setCreateForm({ username: '', email: '', password: '', role: 'user' })
toast.success(t('admin.toast.userCreated'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('admin.toast.createError')))
}
}
const handleCreateInvite = async () => {
try {
const data = await adminApi.createInvite({
max_uses: inviteForm.max_uses,
expires_in_days: inviteForm.expires_in_days || undefined,
})
setInvites(prev => [data.invite, ...prev])
setShowCreateInvite(false)
setInviteForm({ max_uses: 1, expires_in_days: 7 })
// Copy link to clipboard
const link = `${window.location.origin}/register?invite=${data.invite.token}`
navigator.clipboard.writeText(link).then(() => toast.success(t('admin.invite.copied')))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('admin.invite.createError')))
}
}
const handleDeleteInvite = async (id: number) => {
try {
await adminApi.deleteInvite(id)
setInvites(prev => prev.filter(i => i.id !== id))
toast.success(t('admin.invite.deleted'))
} catch {
toast.error(t('admin.invite.deleteError'))
}
}
const copyInviteLink = (token: string) => {
const link = `${window.location.origin}/register?invite=${token}`
navigator.clipboard.writeText(link).then(() => toast.success(t('admin.invite.copied')))
}
const handleEditUser = (user) => {
setEditingUser(user)
setEditForm({ username: user.username, email: user.email, role: user.role, password: '' })
}
const handleSaveUser = async () => {
try {
const payload: { username?: string; email?: string; role: string; password?: string } = {
username: editForm.username.trim() || undefined,
email: editForm.email.trim() || undefined,
role: editForm.role,
}
if (editForm.password.trim()) {
if (editForm.password.trim().length < 8) {
toast.error(t('settings.passwordTooShort'))
return
}
payload.password = editForm.password.trim()
}
const data = await adminApi.updateUser(editingUser.id, payload)
setUsers(prev => prev.map(u => u.id === editingUser.id ? data.user : u))
setEditingUser(null)
toast.success(t('admin.toast.userUpdated'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('admin.toast.updateError')))
}
}
const handleDeleteUser = async (user) => {
if (user.id === currentUser?.id) {
toast.error(t('admin.toast.cannotDeleteSelf'))
return
}
if (!confirm(t('admin.deleteUser', { name: user.username }))) return
try {
await adminApi.deleteUser(user.id)
setUsers(prev => prev.filter(u => u.id !== user.id))
toast.success(t('admin.toast.userDeleted'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('admin.toast.deleteError')))
}
}
return (
<div className="min-h-screen" style={{ background: 'var(--bg-secondary)' }}>
<Navbar />
<div style={{ paddingTop: 'var(--nav-h)' }}>
<PageShell background="var(--bg-secondary)">
<div className="w-full px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
@@ -1612,7 +1302,6 @@ export default function AdminPage(): React.ReactElement {
{activeTab === 'dev-notifications' && <DevNotificationsPanel />}
</PageSidebar>
</div>
</div>
{/* Create user modal */}
<Modal
@@ -1876,6 +1565,6 @@ docker run -d --name trek \\
</div>
</div>
</Modal>
</div>
</PageShell>
)
}
+28 -709
View File
@@ -1,48 +1,12 @@
import React, { useEffect, useMemo, useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../i18n'
import { useSettingsStore } from '../store/settingsStore'
import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from '../i18n'
import Navbar from '../components/Layout/Navbar'
import apiClient, { mapsApi } from '../api/client'
import apiClient from '../api/client'
import CustomSelect from '../components/shared/CustomSelect'
import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react'
import L from 'leaflet'
import type { AtlasPlace, GeoJsonFeatureCollection, TranslationFn } from '../types'
// Convert country code to flag emoji
interface AtlasCountry {
code: string
tripCount: number
placeCount: number
firstVisit?: string | null
lastVisit?: string | null
}
interface AtlasStats {
totalTrips: number
totalPlaces: number
totalCountries: number
totalDays: number
totalCities?: number
}
interface AtlasData {
countries: AtlasCountry[]
stats: AtlasStats
mostVisited?: AtlasCountry | null
continents?: Record<string, number>
lastTrip?: { id: number; title: string; countryCode?: string } | null
nextTrip?: { id: number; title: string; countryCode?: string } | null
streak?: number
firstYear?: number
tripsThisYear?: number
}
interface CountryDetail {
places: AtlasPlace[]
trips: { id: number; title: string }[]
manually_marked?: boolean
}
import type { TranslationFn } from '../types'
import { A2_TO_A3, countryCodeToFlag, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
import { useAtlas } from './atlas/useAtlas'
function MobileStats({ data, stats, countries, resolveName, t, dark }: { data: AtlasData | null; stats: AtlasStats; countries: AtlasCountry[]; resolveName: (code: string) => string; t: TranslationFn; dark: boolean }): React.ReactElement {
const tp = dark ? '#f1f5f9' : '#0f172a'
@@ -93,674 +57,29 @@ function MobileStats({ data, stats, countries, resolveName, t, dark }: { data: A
)
}
function countryCodeToFlag(code: string): string {
if (!code || code.length !== 2) return ''
return String.fromCodePoint(...[...code.toUpperCase()].map(c => 0x1F1E6 + c.charCodeAt(0) - 65))
}
function useCountryNames(language: string): (code: string) => string {
const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code)
useEffect(() => {
try {
const dn = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' })
setResolver(() => (code: string) => { try { return dn.of(code) || code } catch { return code } })
} catch { /* */ }
}, [language])
return resolver
}
// ISO-3166-1 alpha-2 → alpha-3 mapping. Two sources feed this table:
// 1. Hardcoded entries below — REQUIRED for any country whose Natural Earth GeoJSON record
// has ISO_A2='-99' (e.g. France=FRA, Norway=NOR). The runtime augmentation loop
// (see geoData useEffect below) skips '-99' features, so those countries MUST be
// listed here or the atlas_country_options A3-fallback will silently fail.
// 2. Runtime augmentation — the geoData load effect adds entries for every feature
// that has a valid ISO_A2, covering territories not present below.
const A2_TO_A3: Record<string, string> = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AG":"ATG","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BS":"BHS","BH":"BHR","BD":"BGD","BB":"BRB","BY":"BLR","BE":"BEL","BZ":"BLZ","BJ":"BEN","BT":"BTN","BO":"BOL","BA":"BIH","BW":"BWA","BR":"BRA","BN":"BRN","BG":"BGR","BF":"BFA","BI":"BDI","CV":"CPV","KH":"KHM","CM":"CMR","CA":"CAN","CF":"CAF","TD":"TCD","CL":"CHL","CN":"CHN","CO":"COL","KM":"COM","CG":"COG","CD":"COD","CR":"CRI","CI":"CIV","HR":"HRV","CU":"CUB","CY":"CYP","CZ":"CZE","DK":"DNK","DJ":"DJI","DM":"DMA","DO":"DOM","EC":"ECU","EG":"EGY","SV":"SLV","GQ":"GNQ","ER":"ERI","EE":"EST","SZ":"SWZ","ET":"ETH","FJ":"FJI","FI":"FIN","FR":"FRA","GA":"GAB","GM":"GMB","GE":"GEO","DE":"DEU","GH":"GHA","GR":"GRC","GD":"GRD","GT":"GTM","GN":"GIN","GW":"GNB","GY":"GUY","HT":"HTI","HN":"HND","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JM":"JAM","JP":"JPN","JO":"JOR","KZ":"KAZ","KE":"KEN","KI":"KIR","KP":"PRK","KR":"KOR","KW":"KWT","KG":"KGZ","LA":"LAO","LV":"LVA","LB":"LBN","LS":"LSO","LR":"LBR","LY":"LBY","LI":"LIE","LT":"LTU","LU":"LUX","MG":"MDG","MW":"MWI","MY":"MYS","MV":"MDV","ML":"MLI","MT":"MLT","MR":"MRT","MU":"MUS","MX":"MEX","MD":"MDA","MN":"MNG","ME":"MNE","MA":"MAR","MZ":"MOZ","MM":"MMR","NA":"NAM","NP":"NPL","NL":"NLD","NZ":"NZL","NI":"NIC","NE":"NER","NG":"NGA","MK":"MKD","NO":"NOR","OM":"OMN","PK":"PAK","PA":"PAN","PG":"PNG","PY":"PRY","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","QA":"QAT","RO":"ROU","RU":"RUS","RW":"RWA","SA":"SAU","SN":"SEN","RS":"SRB","SL":"SLE","SG":"SGP","SK":"SVK","SI":"SVN","SB":"SLB","SO":"SOM","ZA":"ZAF","SS":"SSD","ES":"ESP","LK":"LKA","SD":"SDN","SR":"SUR","SE":"SWE","CH":"CHE","SY":"SYR","TW":"TWN","TJ":"TJK","TZ":"TZA","TH":"THA","TL":"TLS","TG":"TGO","TT":"TTO","TN":"TUN","TR":"TUR","TM":"TKM","UG":"UGA","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","UY":"URY","UZ":"UZB","VU":"VUT","VE":"VEN","VN":"VNM","YE":"YEM","ZM":"ZMB","ZW":"ZWE"}
export default function AtlasPage(): React.ReactElement {
const { t, language } = useTranslation()
const { settings } = useSettingsStore()
const navigate = useNavigate()
const resolveName = useCountryNames(language)
const dm = settings.dark_mode
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const mapRef = useRef<HTMLDivElement>(null)
const mapInstance = useRef<L.Map | null>(null)
const geoLayerRef = useRef<L.GeoJSON | null>(null)
const glareRef = useRef<HTMLDivElement>(null)
const borderGlareRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const country_layer_by_a2_ref = useRef<Record<string, any>>({})
const handlePanelMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return
const rect = panelRef.current.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
// Subtle inner glow
glareRef.current.style.background = `radial-gradient(circle 300px at ${x}px ${y}px, ${dark ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.25)'} 0%, transparent 70%)`
glareRef.current.style.opacity = '1'
// Border glow that follows cursor
borderGlareRef.current.style.opacity = '1'
borderGlareRef.current.style.maskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
borderGlareRef.current.style.webkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
}
const handlePanelMouseLeave = () => {
if (glareRef.current) glareRef.current.style.opacity = '0'
if (borderGlareRef.current) borderGlareRef.current.style.opacity = '0'
}
const [data, setData] = useState<AtlasData | null>(null)
const [loading, setLoading] = useState<boolean>(true)
const [sidebarOpen, setSidebarOpen] = useState<boolean>(true)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<boolean>(false)
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
const [countryDetail, setCountryDetail] = useState<CountryDetail | null>(null)
const [geoData, setGeoData] = useState<GeoJsonFeatureCollection | null>(null)
const [visitedRegions, setVisitedRegions] = useState<Record<string, { code: string; name: string; placeCount: number; manuallyMarked?: boolean }[]>>({})
const regionLayerRef = useRef<L.GeoJSON | null>(null)
const regionGeoCache = useRef<Record<string, GeoJsonFeatureCollection>>({})
const [showRegions, setShowRegions] = useState(false)
const [regionGeoLoaded, setRegionGeoLoaded] = useState(0)
const regionTooltipRef = useRef<HTMLDivElement>(null)
const loadCountryDetailRef = useRef<(code: string) => void>(() => {})
const handleMarkCountryRef = useRef<(code: string, name: string) => void>(() => {})
const setConfirmActionRef = useRef<typeof setConfirmAction>(() => {})
const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket' | 'choose-region' | 'unmark-region'; code: string; name: string; regionCode?: string; countryName?: string } | null>(null)
const [bucketMonth, setBucketMonth] = useState(0)
const [bucketYear, setBucketYear] = useState(0)
// Bucket list
interface BucketItem { id: number; name: string; lat: number | null; lng: number | null; country_code: string | null; notes: string | null; target_date: string | null }
const [bucketList, setBucketList] = useState<BucketItem[]>([])
const [showBucketAdd, setShowBucketAdd] = useState(false)
const [bucketForm, setBucketForm] = useState({ name: '', notes: '', lat: '', lng: '', target_date: '' })
const [bucketSearch, setBucketSearch] = useState('')
const [bucketSearchResults, setBucketSearchResults] = useState<any[]>([])
const [bucketSearching, setBucketSearching] = useState(false)
const [bucketPoiMonth, setBucketPoiMonth] = useState(0)
const [bucketPoiYear, setBucketPoiYear] = useState(0)
const [bucketTab, setBucketTab] = useState<'stats' | 'bucket'>('stats')
const bucketMarkersRef = useRef<any>(null)
const [atlas_country_search, set_atlas_country_search] = useState('')
const [atlas_country_results, set_atlas_country_results] = useState<{ code: string; label: string }[]>([])
const [atlas_country_open, set_atlas_country_open] = useState(false)
const atlas_country_options = useMemo(() => {
if (!geoData) return []
// Precompute A3 → A2 reverse lookup once per geoData change instead of
// scanning A2_TO_A3 for every feature that needs the fallback.
const a3ToA2 = new Map<string, string>()
for (const [a2Key, a3Val] of Object.entries(A2_TO_A3)) a3ToA2.set(a3Val, a2Key)
const opts: { code: string; label: string }[] = []
const seen = new Set<string>()
for (const f of (geoData as any).features || []) {
const rawA2 = f?.properties?.ISO_A2
let resolvedA2: string | null = (typeof rawA2 === 'string' && rawA2.length === 2 && rawA2 !== '-99') ? rawA2 : null
if (!resolvedA2) {
const a3 = f?.properties?.ADM0_A3 || f?.properties?.ISO_A3 || f?.properties?.['ISO3166-1-Alpha-3'] || null
if (a3 && a3 !== '-99') resolvedA2 = a3ToA2.get(a3) ?? null
}
if (!resolvedA2 || seen.has(resolvedA2)) continue
seen.add(resolvedA2)
const label = String(resolveName(resolvedA2) || f?.properties?.NAME || f?.properties?.ADMIN || resolvedA2)
opts.push({ code: resolvedA2, label })
}
opts.sort((a, b) => a.label.localeCompare(b.label))
return opts
}, [geoData, resolveName])
// Load atlas data + bucket list
useEffect(() => {
Promise.all([
apiClient.get('/addons/atlas/stats'),
apiClient.get('/addons/atlas/bucket-list'),
]).then(([statsRes, bucketRes]) => {
setData(statsRes.data)
setBucketList(bucketRes.data.items || [])
setLoading(false)
}).catch(() => setLoading(false))
}, [])
// Load GeoJSON world data (direct GeoJSON, no conversion needed)
useEffect(() => {
fetch('https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson')
.then(r => r.json())
.then(geo => {
// Dynamically build A2→A3 mapping from GeoJSON
for (const f of geo.features) {
const a2 = f.properties?.ISO_A2
const a3 = f.properties?.ADM0_A3 || f.properties?.ISO_A3
if (a2 && a3 && a2 !== '-99' && a3 !== '-99' && !A2_TO_A3[a2]) {
A2_TO_A3[a2] = a3
}
}
setGeoData(geo)
})
.catch(() => {})
}, [])
// Load visited regions (geocoded from places/trips) — once on mount
useEffect(() => {
apiClient.get(`/addons/atlas/regions?_t=${Date.now()}`)
.then(r => setVisitedRegions(r.data?.regions || {}))
.catch(() => {})
}, [])
// Load admin-1 GeoJSON for countries visible in the current viewport
const loadRegionsForViewportRef = useRef<() => void>(() => {})
const loadRegionsForViewport = (): void => {
if (!mapInstance.current) return
const bounds = mapInstance.current.getBounds()
const toLoad: string[] = []
for (const [code, layer] of Object.entries(country_layer_by_a2_ref.current)) {
if (regionGeoCache.current[code]) continue
try {
if (bounds.intersects((layer as any).getBounds())) toLoad.push(code)
} catch {}
}
if (!toLoad.length) return
apiClient.get(`/addons/atlas/regions/geo?countries=${toLoad.join(',')}`)
.then(geoRes => {
const geo = geoRes.data
if (!geo?.features) return
let added = false
for (const c of toLoad) {
const features = geo.features.filter((f: any) => f.properties?.iso_a2?.toUpperCase() === c)
if (features.length > 0) { regionGeoCache.current[c] = { type: 'FeatureCollection', features }; added = true }
}
if (added) setRegionGeoLoaded(v => v + 1)
})
.catch(() => {})
}
loadRegionsForViewportRef.current = loadRegionsForViewport
// Initialize map — runs after loading is done and mapRef is available
useEffect(() => {
if (loading || !mapRef.current) return
if (mapInstance.current) { mapInstance.current.remove(); mapInstance.current = null }
const map = L.map(mapRef.current, {
center: [25, 0],
zoom: 3,
minZoom: 3,
maxZoom: 10,
zoomControl: false,
attributionControl: false,
maxBounds: [[-90, -220], [90, 220]],
maxBoundsViscosity: 1.0,
fadeAnimation: false,
preferCanvas: true,
})
L.control.zoom({ position: 'bottomright' }).addTo(map)
const tileUrl = dark
? 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png'
L.tileLayer(tileUrl, {
maxZoom: 10,
keepBuffer: 25,
updateWhenZooming: true,
updateWhenIdle: false,
tileSize: 256,
zoomOffset: 0,
crossOrigin: true,
referrerPolicy: 'strict-origin-when-cross-origin',
} as any).addTo(map)
// Preload adjacent zoom level tiles
L.tileLayer(tileUrl, {
maxZoom: 10,
keepBuffer: 10,
opacity: 0,
tileSize: 256,
crossOrigin: true,
referrerPolicy: 'strict-origin-when-cross-origin',
}).addTo(map)
// Custom pane for region layer — above overlay (z-index 400)
map.createPane('regionPane')
map.getPane('regionPane')!.style.zIndex = '401'
mapInstance.current = map
// Zoom-based region switching
map.on('zoomend', () => {
const z = map.getZoom()
const shouldShow = z >= 5
setShowRegions(shouldShow)
const overlayPane = map.getPane('overlayPane')
if (overlayPane) {
overlayPane.style.opacity = shouldShow ? '0.35' : '1'
overlayPane.style.pointerEvents = shouldShow ? 'none' : 'auto'
}
if (shouldShow) {
// Re-add region layer if it was removed while zoomed out
if (regionLayerRef.current && !map.hasLayer(regionLayerRef.current)) {
regionLayerRef.current.addTo(map)
}
loadRegionsForViewportRef.current()
} else {
// Physically remove region layer so its SVG paths can't intercept events
if (regionTooltipRef.current) regionTooltipRef.current.style.display = 'none'
if (regionLayerRef.current && map.hasLayer(regionLayerRef.current)) {
regionLayerRef.current.resetStyle()
regionLayerRef.current.removeFrom(map)
}
}
})
map.on('moveend', () => {
if (map.getZoom() >= 6) loadRegionsForViewportRef.current()
})
return () => { map.remove(); mapInstance.current = null }
}, [dark, loading])
// Render GeoJSON countries
useEffect(() => {
if (!mapInstance.current || !geoData || !data) return
const visitedA3 = new Set(data.countries.map(c => A2_TO_A3[c.code]).filter(Boolean))
const countryMap = {}
data.countries.forEach(c => { if (A2_TO_A3[c.code]) countryMap[A2_TO_A3[c.code]] = c })
// Preserve current map view
const currentCenter = mapInstance.current.getCenter()
const currentZoom = mapInstance.current.getZoom()
if (geoLayerRef.current) {
mapInstance.current.removeLayer(geoLayerRef.current)
}
// Generate deterministic color per country code
const VISITED_COLORS = ['#6366f1','#ec4899','#14b8a6','#f97316','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#e11d48','#0d9488','#7c3aed','#2563eb','#dc2626','#059669','#d946ef']
// Assign colors in order of visit (by index in countries array) so no two neighbors share a color easily
const visitedA3List = [...visitedA3]
const colorMap = {}
visitedA3List.forEach((a3, i) => { colorMap[a3] = VISITED_COLORS[i % VISITED_COLORS.length] })
const colorForCode = (a3) => colorMap[a3] || VISITED_COLORS[0]
const canvasRenderer = L.canvas({ padding: 0.5, tolerance: 5 })
geoLayerRef.current = L.geoJSON(geoData, {
renderer: canvasRenderer,
interactive: true,
bubblingMouseEvents: false,
style: (feature) => {
const a3 = feature.properties?.ADM0_A3 || feature.properties?.ISO_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id
const visited = visitedA3.has(a3)
return {
fillColor: visited ? colorForCode(a3) : (dark ? '#1e1e2e' : '#e2e8f0'),
fillOpacity: visited ? 0.7 : 0.3,
color: dark ? '#333' : '#cbd5e1',
weight: 0.5,
}
},
onEachFeature: (feature, layer) => {
const a3 = feature.properties?.ADM0_A3 || feature.properties?.ISO_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id
const c = countryMap[a3]
if (c) {
country_layer_by_a2_ref.current[c.code] = layer
const name = resolveName(c.code)
const formatDate = (d) => { if (!d) return '—'; const dt = new Date(d); return dt.toLocaleDateString(getLocaleForLanguage(language), { month: 'short', year: 'numeric' }) }
const tooltipHtml = `
<div style="display:flex;flex-direction:column;gap:8px;min-width:160px">
<div style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.1em;padding-bottom:6px;border-bottom:1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}">${name}</div>
<div style="display:flex;gap:14px">
<div><span style="font-size:16px;font-weight:800">${c.tripCount}</span> <span style="font-size:10px;opacity:0.5;text-transform:uppercase;letter-spacing:0.05em">${c.tripCount === 1 ? t('atlas.tripSingular') : t('atlas.tripPlural')}</span></div>
<div><span style="font-size:16px;font-weight:800">${c.placeCount}</span> <span style="font-size:10px;opacity:0.5;text-transform:uppercase;letter-spacing:0.05em">${c.placeCount === 1 ? t('atlas.placeVisited') : t('atlas.placesVisited')}</span></div>
</div>
<div style="display:flex;gap:2px;border-top:1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'};padding-top:8px">
<div style="flex:1;display:flex;flex-direction:column;gap:2px">
<span style="font-size:9px;text-transform:uppercase;letter-spacing:0.08em;opacity:0.4">${t('atlas.firstVisit')}</span>
<span style="font-size:12px;font-weight:700">${formatDate(c.firstVisit)}</span>
</div>
<div style="flex:1;display:flex;flex-direction:column;gap:2px">
<span style="font-size:9px;text-transform:uppercase;letter-spacing:0.08em;opacity:0.4">${t('atlas.lastVisitLabel')}</span>
<span style="font-size:12px;font-weight:700">${formatDate(c.lastVisit)}</span>
</div>
</div>
</div>
</div>`
layer.bindTooltip(tooltipHtml, {
sticky: false, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
})
layer.on('click', () => {
if (c.placeCount === 0 && c.tripCount === 0) {
handleUnmarkCountry(c.code)
}
})
layer.on('mouseover', (e) => {
e.target.setStyle({ fillOpacity: 0.9, weight: 2, color: dark ? '#818cf8' : '#4f46e5' })
})
layer.on('mouseout', (e) => {
geoLayerRef.current.resetStyle(e.target)
})
} else {
// Unvisited country — allow clicking to mark as visited
// Reverse lookup: find A2 code from A3, or use A3 directly
const a3ToA2Entry = Object.entries(A2_TO_A3).find(([, v]) => v === a3)
const isoA2 = feature.properties?.ISO_A2
const countryCode = a3ToA2Entry ? a3ToA2Entry[0] : (isoA2 && isoA2 !== '-99' ? isoA2 : null)
if (countryCode && countryCode !== '-99') {
country_layer_by_a2_ref.current[countryCode] = layer
const name = feature.properties?.NAME || feature.properties?.ADMIN || resolveName(countryCode)
layer.bindTooltip(`<div style="font-size:12px;font-weight:600">${name}</div>`, {
sticky: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
})
layer.on('click', () => handleMarkCountry(countryCode, name))
layer.on('mouseover', (e) => {
e.target.setStyle({ fillOpacity: 0.5, weight: 1.5, color: dark ? '#555' : '#94a3b8' })
})
layer.on('mouseout', (e) => {
geoLayerRef.current.resetStyle(e.target)
})
}
}
}
}).addTo(mapInstance.current)
// Restore map view after re-render
mapInstance.current.setView(currentCenter, currentZoom, { animate: false })
}, [geoData, data, dark])
// Render sub-national region layer (zoom >= 5)
useEffect(() => {
if (!mapInstance.current) return
// Remove existing region layer
if (regionLayerRef.current) {
mapInstance.current.removeLayer(regionLayerRef.current)
regionLayerRef.current = null
}
if (Object.keys(regionGeoCache.current).length === 0) return
// Build set of visited region codes and per-country name sets
const visitedRegionCodes = new Set<string>()
const visitedRegionNamesByCountry = new Map<string, Set<string>>()
const regionPlaceCounts: Record<string, number> = {}
for (const [countryCode, regions] of Object.entries(visitedRegions)) {
const names = new Set<string>()
for (const r of regions) {
visitedRegionCodes.add(r.code)
names.add(r.name.toLowerCase())
regionPlaceCounts[r.code] = r.placeCount
regionPlaceCounts[`${countryCode}:${r.name.toLowerCase()}`] = r.placeCount
}
visitedRegionNamesByCountry.set(countryCode, names)
}
// Match feature by ISO code OR region name scoped to the feature's country
const isVisitedFeature = (f: any) => {
if (visitedRegionCodes.has(f.properties?.iso_3166_2)) return true
const countryA2 = (f.properties?.iso_a2 || '').toUpperCase()
const countryNames = visitedRegionNamesByCountry.get(countryA2)
if (!countryNames) return false
const name = (f.properties?.name || '').toLowerCase()
if (countryNames.has(name)) return true
const nameEn = (f.properties?.name_en || '').toLowerCase()
if (nameEn && countryNames.has(nameEn)) return true
return false
}
// Include ALL region features — visited ones get colored fill, unvisited get outline only
const allFeatures: any[] = []
for (const geo of Object.values(regionGeoCache.current)) {
for (const f of geo.features) {
allFeatures.push(f)
}
}
if (allFeatures.length === 0) return
// Use same colors as country layer
const VISITED_COLORS = ['#6366f1','#ec4899','#14b8a6','#f97316','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#e11d48','#0d9488','#7c3aed','#2563eb','#dc2626','#059669','#d946ef']
const countryA3Set = data ? data.countries.map(c => A2_TO_A3[c.code]).filter(Boolean) : []
const countryColorMap: Record<string, string> = {}
countryA3Set.forEach((a3, i) => { countryColorMap[a3] = VISITED_COLORS[i % VISITED_COLORS.length] })
// Map country A2 code to country color
const a2ColorMap: Record<string, string> = {}
if (data) data.countries.forEach(c => { if (A2_TO_A3[c.code] && countryColorMap[A2_TO_A3[c.code]]) a2ColorMap[c.code] = countryColorMap[A2_TO_A3[c.code]] })
const mergedGeo = { type: 'FeatureCollection', features: allFeatures }
const svgRenderer = L.svg({ pane: 'regionPane' })
regionLayerRef.current = L.geoJSON(mergedGeo as any, {
renderer: svgRenderer,
interactive: true,
pane: 'regionPane',
style: (feature) => {
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
const visited = isVisitedFeature(feature)
return visited ? {
fillColor: a2ColorMap[countryA2] || '#6366f1',
fillOpacity: 0.85,
color: dark ? '#888' : '#64748b',
weight: 1.2,
} : {
fillColor: dark ? '#ffffff' : '#000000',
fillOpacity: 0.03,
color: dark ? '#555' : '#94a3b8',
weight: 1,
}
},
onEachFeature: (feature, layer) => {
const regionName = feature?.properties?.name || ''
const regionNameEn = feature?.properties?.name_en || ''
const countryName = feature?.properties?.admin || ''
const regionCode = feature?.properties?.iso_3166_2 || ''
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
const visited = isVisitedFeature(feature)
const count = regionPlaceCounts[regionCode] || regionPlaceCounts[`${countryA2}:${regionName.toLowerCase()}`] || regionPlaceCounts[`${countryA2}:${regionNameEn.toLowerCase()}`] || 0
layer.on('click', () => {
if (!countryA2) return
if (visited) {
const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode || r.name.toLowerCase() === regionNameEn.toLowerCase())
if (regionEntry?.manuallyMarked) {
setConfirmActionRef.current({
type: 'unmark-region',
code: countryA2,
name: regionName,
regionCode,
countryName,
})
} else {
loadCountryDetailRef.current(countryA2)
}
} else {
setConfirmActionRef.current({
type: 'choose-region',
code: countryA2, // country A2 code — used for flag display
name: regionName, // region name — shown as heading
regionCode,
countryName,
})
}
})
layer.on('mouseover', (e: any) => {
e.target.setStyle(visited
? { fillOpacity: 0.95, weight: 2, color: dark ? '#818cf8' : '#4f46e5' }
: { fillOpacity: 0.15, fillColor: dark ? '#818cf8' : '#4f46e5', weight: 1.5, color: dark ? '#818cf8' : '#4f46e5' }
)
const tt = regionTooltipRef.current
if (tt) {
tt.style.display = 'block'
tt.style.left = e.originalEvent.clientX + 12 + 'px'
tt.style.top = e.originalEvent.clientY - 10 + 'px'
tt.innerHTML = visited
? `<div style="font-weight:600;margin-bottom:3px">${regionName}</div><div style="opacity:0.5;font-size:10px">${countryName}</div><div style="margin-top:5px;font-size:11px"><b>${count}</b> ${count === 1 ? 'place' : 'places'}</div>`
: `<div style="font-weight:600;margin-bottom:3px">${regionName}</div><div style="opacity:0.5;font-size:10px">${countryName}</div>`
}
})
layer.on('mousemove', (e: any) => {
const tt = regionTooltipRef.current
if (tt) { tt.style.left = e.originalEvent.clientX + 12 + 'px'; tt.style.top = e.originalEvent.clientY - 10 + 'px' }
})
layer.on('mouseout', (e: any) => {
regionLayerRef.current?.resetStyle(e.target)
const tt = regionTooltipRef.current
if (tt) tt.style.display = 'none'
})
},
})
// Only add to map if currently in region mode — otherwise hold it ready for when user zooms in
if (mapInstance.current.getZoom() >= 6) {
regionLayerRef.current.addTo(mapInstance.current)
}
}, [regionGeoLoaded, visitedRegions, dark, t])
const handleMarkCountry = (code: string, name: string): void => {
setConfirmAction({ type: 'choose', code, name })
}
handleMarkCountryRef.current = handleMarkCountry
setConfirmActionRef.current = setConfirmAction
const handleUnmarkCountry = (code: string): void => {
const country = data?.countries.find(c => c.code === code)
setConfirmAction({ type: 'unmark', code, name: resolveName(code) })
}
const select_country_from_search = (country_code: string): void => {
const country_label = resolveName(country_code)
set_atlas_country_search(country_label)
set_atlas_country_open(false)
set_atlas_country_results([])
const layer = country_layer_by_a2_ref.current[country_code]
try {
if (layer?.getBounds && mapInstance.current) {
mapInstance.current.fitBounds(layer.getBounds(), { padding: [24, 24], animate: true, maxZoom: 6 })
}
} catch (e ) {
console.error('Error fitting bounds', e)
}
setConfirmAction({ type: 'choose', code: country_code, name: country_label })
}
const executeConfirmAction = async (): Promise<void> => {
if (!confirmAction) return
const { type, code } = confirmAction
setConfirmAction(null)
// Update local state immediately (no API reload = no map re-render flash)
if (type === 'mark') {
apiClient.post(`/addons/atlas/country/${code}/mark`).catch(() => {})
setData(prev => {
if (!prev || prev.countries.find(c => c.code === code)) return prev
return {
...prev,
countries: [...prev.countries, { code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }],
stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 },
}
})
} else {
apiClient.delete(`/addons/atlas/country/${code}/mark`).catch(() => {})
setSelectedCountry(null)
setCountryDetail(null)
setData(prev => {
if (!prev) return prev
const c = prev.countries.find(c => c.code === code)
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
return {
...prev,
countries: prev.countries.filter(c => c.code !== code),
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
}
})
setVisitedRegions(prev => {
if (!prev[code]) return prev
const next = { ...prev }
delete next[code]
return next
})
}
}
const handleAddBucketItem = async (): Promise<void> => {
if (!bucketForm.name.trim()) return
try {
const data: Record<string, unknown> = { name: bucketForm.name.trim() }
if (bucketForm.notes.trim()) data.notes = bucketForm.notes.trim()
if (bucketForm.lat && bucketForm.lng) { data.lat = parseFloat(bucketForm.lat); data.lng = parseFloat(bucketForm.lng) }
const targetDate = bucketForm.target_date || (bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null)
if (targetDate) data.target_date = targetDate
const r = await apiClient.post('/addons/atlas/bucket-list', data)
setBucketList(prev => [r.data.item, ...prev])
setBucketForm({ name: '', notes: '', lat: '', lng: '', target_date: '' })
setBucketSearch(''); setBucketSearchResults([]); setBucketPoiMonth(0); setBucketPoiYear(0)
setShowBucketAdd(false)
} catch { /* */ }
}
const handleDeleteBucketItem = async (id: number): Promise<void> => {
try {
await apiClient.delete(`/addons/atlas/bucket-list/${id}`)
setBucketList(prev => prev.filter(i => i.id !== id))
} catch { /* */ }
}
const handleBucketPoiSearch = async () => {
if (!bucketSearch.trim()) return
setBucketSearching(true)
try {
const result = await mapsApi.search(bucketSearch, language)
setBucketSearchResults(result.places || [])
} catch {} finally { setBucketSearching(false) }
}
const handleSelectBucketPoi = (result: any) => {
const targetDate = bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null
setBucketForm({
name: result.name || bucketSearch,
notes: '',
lat: String(result.lat || ''),
lng: String(result.lng || ''),
target_date: targetDate || '',
})
setBucketSearchResults([])
setBucketSearch('')
}
// Render bucket list markers on map
useEffect(() => {
if (!mapInstance.current) return
if (bucketMarkersRef.current) {
mapInstance.current.removeLayer(bucketMarkersRef.current)
}
if (bucketList.length === 0) return
const markers = bucketList.filter(b => b.lat && b.lng).map(b => {
const icon = L.divIcon({
className: '',
html: `<div style="width:28px;height:28px;border-radius:50%;background:rgba(251,191,36,0.9);display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,0.3);border:2px solid white"><svg width="14" height="14" viewBox="0 0 24 24" fill="white" stroke="none"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></div>`,
iconSize: [28, 28],
iconAnchor: [14, 14],
})
return L.marker([b.lat!, b.lng!], { icon }).bindTooltip(
`<div style="font-size:12px;font-weight:600">${b.name}</div>${b.notes ? `<div style="font-size:10px;opacity:0.7;margin-top:2px">${b.notes}</div>` : ''}`,
{ className: 'atlas-tooltip', direction: 'top', offset: [0, -14] }
)
})
bucketMarkersRef.current = L.layerGroup(markers).addTo(mapInstance.current)
}, [bucketList])
const loadCountryDetail = async (code: string): Promise<void> => {
setSelectedCountry(code)
try {
const r = await apiClient.get(`/addons/atlas/country/${code}`)
setCountryDetail(r.data)
} catch { /* */ }
}
loadCountryDetailRef.current = loadCountryDetail
const stats = data?.stats || { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 }
const countries = data?.countries || []
// Page = wiring container: the whole interactive globe (map lifecycle, atlas +
// bucket data, mark/unmark flows, country search) lives in useAtlas. The page
// only wires that state into JSX and its presentational SidebarContent helper.
const {
t, language, navigate, resolveName, dark, loading,
mapRef, regionTooltipRef, panelRef, glareRef, borderGlareRef,
handlePanelMouseMove, handlePanelMouseLeave,
data, setData, stats, countries, selectedCountry, countryDetail,
loadCountryDetail, handleUnmarkCountry, select_country_from_search,
visitedRegions, setVisitedRegions,
atlas_country_search, set_atlas_country_search,
atlas_country_results, set_atlas_country_results,
atlas_country_open, set_atlas_country_open, atlas_country_options,
confirmAction, setConfirmAction, executeConfirmAction,
bucketMonth, setBucketMonth, bucketYear, setBucketYear,
bucketList, setBucketList, bucketTab, setBucketTab,
showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm,
handleAddBucketItem, handleDeleteBucketItem, handleBucketPoiSearch, handleSelectBucketPoi,
bucketSearchResults, setBucketSearchResults,
bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear,
bucketSearching, bucketSearch, setBucketSearch,
} = useAtlas()
if (loading) {
return (
+22 -221
View File
@@ -1,10 +1,5 @@
import React, { useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { tripsApi, authApi, reservationsApi } from '../api/client'
import { tripRepo } from '../repo/tripRepo'
import { useAuthStore } from '../store/authStore'
import { useTranslation } from '../i18n'
import { getApiErrorMessage } from '../types'
import Navbar from '../components/Layout/Navbar'
import DemoBanner from '../components/Layout/DemoBanner'
import TripFormModal from '../components/Trips/TripFormModal'
@@ -13,7 +8,11 @@ import CopyTripDialog from '../components/shared/CopyTripDialog'
import CustomSelect from '../components/shared/CustomSelect'
import PlaceAvatar from '../components/shared/PlaceAvatar'
import MobileTopBar from '../components/Layout/MobileTopBar'
import { useToast } from '../components/shared/Toast'
import { useDashboard } from './dashboard/useDashboard'
import {
type DashboardTrip, type HeroBundle, type TravelStats, type UpcomingReservation,
MS_PER_DAY, daysUntil, getTripStatus,
} from './dashboard/dashboardModel'
import {
Plus, Edit2, Trash2, Archive, Copy, ArrowRight, MapPin,
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
@@ -21,58 +20,6 @@ import {
} from 'lucide-react'
import '../styles/dashboard.css'
interface DashboardTrip {
id: number
title: string
description?: string | null
start_date?: string | null
end_date?: string | null
cover_image?: string | null
is_archived?: boolean
is_owner?: boolean
owner_username?: string
day_count?: number
place_count?: number
shared_count?: number
[key: string]: string | number | boolean | null | undefined
}
const MS_PER_DAY = 86400000
function daysUntil(dateStr: string | null | undefined): number | null {
if (!dateStr) return null
const today = new Date(); today.setHours(0, 0, 0, 0)
const d = new Date(dateStr + 'T00:00:00'); d.setHours(0, 0, 0, 0)
return Math.round((d.getTime() - today.getTime()) / MS_PER_DAY)
}
function getTripStatus(trip: DashboardTrip): 'ongoing' | 'today' | 'tomorrow' | 'future' | 'past' | null {
const today = new Date().toISOString().split('T')[0]
if (trip.start_date && trip.end_date && trip.start_date <= today && trip.end_date >= today) return 'ongoing'
const until = daysUntil(trip.start_date)
if (until === null) return null
if (until === 0) return 'today'
if (until === 1) return 'tomorrow'
if (until > 1) return 'future'
return 'past'
}
function sortTrips(trips: DashboardTrip[]): DashboardTrip[] {
const today = new Date().toISOString().split('T')[0]
const rank = (t: DashboardTrip) => {
if (t.start_date && t.end_date && t.start_date <= today && t.end_date >= today) return 0
if (t.start_date && t.start_date >= today) return 1
return 2
}
return [...trips].sort((a, b) => {
const ra = rank(a), rb = rank(b)
if (ra !== rb) return ra - rb
const ad = a.start_date || '', bd = b.start_date || ''
if (ra <= 1) return ad.localeCompare(bd)
return bd.localeCompare(ad)
})
}
const GRADIENTS = [
'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
@@ -111,20 +58,6 @@ function initials(name: string | null | undefined): string {
return name.slice(0, 2).toUpperCase()
}
interface Member { id: number; username: string; avatar_url?: string | null }
interface Place {
id: number; name: string; image_url: string | null; lat: number | null; lng: number | null
google_place_id: string | null; osm_id: string | null
category_color?: string | null; category_icon?: string | null
}
interface HeroBundle { members: Member[]; places: Place[] }
interface TravelStats { totalTrips?: number; totalDays?: number; totalPlaces?: number; totalDistanceKm?: number; countries?: string[] }
interface UpcomingReservation {
id: number; trip_id: number; title: string; type: string
reservation_time?: string | null; day_date?: string | null
location?: string | null; place_name?: string | null; trip_title?: string | null
}
const RES_ICON: Record<string, React.ReactElement> = {
flight: <Plane size={16} />, hotel: <Hotel size={16} />, restaurant: <Utensils size={16} />,
}
@@ -143,157 +76,24 @@ function useIsMobile(): boolean {
}
export default function DashboardPage(): React.ReactElement {
const [trips, setTrips] = useState<DashboardTrip[]>([])
const [archivedTrips, setArchivedTrips] = useState<DashboardTrip[]>([])
const [isLoading, setIsLoading] = useState<boolean>(true)
const [showForm, setShowForm] = useState<boolean>(false)
const [editingTrip, setEditingTrip] = useState<DashboardTrip | null>(null)
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid')
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
const [tripFilter, setTripFilter] = useState<'planned' | 'archive' | 'completed'>('planned')
const [stats, setStats] = useState<TravelStats | null>(null)
const [upcoming, setUpcoming] = useState<UpcomingReservation[]>([])
const [heroBundle, setHeroBundle] = useState<HeroBundle | null>(null)
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const toast = useToast()
const { t, locale } = useTranslation()
const { demoMode } = useAuthStore()
const toggleViewMode = () => {
setViewMode(prev => {
const next = prev === 'grid' ? 'list' : 'grid'
localStorage.setItem('trek_dashboard_view', next)
return next
})
}
useEffect(() => {
if (searchParams.get('create') === '1') {
setShowForm(true)
setSearchParams({}, { replace: true })
}
}, [searchParams])
useEffect(() => { loadTrips() }, [])
// Travel stats + upcoming reservations power the atlas row and the sidebar.
// Both are best-effort: a failure just leaves that section empty.
useEffect(() => {
authApi.travelStats().then(setStats).catch(() => {})
reservationsApi.upcoming().then((r: { reservations: UpcomingReservation[] }) => setUpcoming(r.reservations || [])).catch(() => {})
}, [])
const loadTrips = async () => {
setIsLoading(true)
try {
const { trips, archivedTrips } = await tripRepo.list()
setTrips(sortTrips(trips))
setArchivedTrips(sortTrips(archivedTrips))
} catch {
toast.error(t('dashboard.toast.loadError'))
} finally {
setIsLoading(false)
}
}
const today = new Date().toISOString().split('T')[0]
const spotlight = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today)
|| trips.find(t => t.start_date && t.start_date >= today)
|| trips[0]
|| null
const rest = spotlight ? trips.filter(t => t.id !== spotlight.id) : trips
// Pull the spotlight trip's members + places so the boarding pass can show
// real buddies and place thumbnails instead of placeholders.
useEffect(() => {
if (!spotlight) { setHeroBundle(null); return }
let cancelled = false
tripsApi.bundle(spotlight.id)
.then((b: HeroBundle) => { if (!cancelled) setHeroBundle({ members: b.members || [], places: b.places || [] }) })
.catch(() => { if (!cancelled) setHeroBundle(null) })
return () => { cancelled = true }
}, [spotlight?.id])
const handleCreate = async (tripData: Record<string, unknown>) => {
try {
const data = await tripsApi.create(tripData)
setTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.created'))
return data
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, t('dashboard.toast.createError')))
}
}
const handleUpdate = async (tripData: Record<string, unknown>) => {
if (!editingTrip) return
try {
const data = await tripsApi.update(editingTrip.id, tripData)
setTrips(prev => sortTrips(prev.map(t => t.id === editingTrip.id ? data.trip : t)))
toast.success(t('dashboard.toast.updated'))
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, t('dashboard.toast.updateError')))
}
}
const confirmDelete = async () => {
if (!deleteTrip) return
try {
await tripsApi.delete(deleteTrip.id)
setTrips(prev => prev.filter(t => t.id !== deleteTrip.id))
setArchivedTrips(prev => prev.filter(t => t.id !== deleteTrip.id))
toast.success(t('dashboard.toast.deleted'))
} catch {
toast.error(t('dashboard.toast.deleteError'))
}
setDeleteTrip(null)
}
const handleArchive = async (id: number) => {
try {
const data = await tripsApi.archive(id)
setTrips(prev => prev.filter(t => t.id !== id))
setArchivedTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.archived'))
} catch {
toast.error(t('dashboard.toast.archiveError'))
}
}
const handleUnarchive = async (id: number) => {
try {
const data = await tripsApi.unarchive(id)
setArchivedTrips(prev => prev.filter(t => t.id !== id))
setTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.restored'))
} catch {
toast.error(t('dashboard.toast.restoreError'))
}
}
const confirmCopy = async () => {
if (!copyTrip) return
try {
const data = await tripsApi.copy(copyTrip.id, { title: `${copyTrip.title} (${t('dashboard.copySuffix')})` })
setTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.copied'))
} catch {
toast.error(t('dashboard.toast.copyError'))
}
setCopyTrip(null)
}
const gridTrips = tripFilter === 'archive' ? archivedTrips
: tripFilter === 'completed' ? rest.filter(t => getTripStatus(t) === 'past')
: rest.filter(t => getTripStatus(t) !== 'past')
// Page = wiring container: all state, data loading and mutations live in the
// useDashboard data hook; this component only renders what it returns.
const {
demoMode, locale, t, navigate,
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
tripFilter, setTripFilter, viewMode, toggleViewMode,
showForm, setShowForm, editingTrip, setEditingTrip,
deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, setTrips,
handleCreate, handleUpdate, confirmDelete, handleArchive, handleUnarchive, confirmCopy,
} = useDashboard()
return (
<div className="trek-dash trek-dash-shell">
<>
{/* Navbar lives outside .trek-dash so it keeps the app-wide font + button
styling instead of inheriting the dashboard scope's Geist font and the
`.trek-dash button` reset (which shifted the bell icon + menu items). */}
<Navbar />
<div className="trek-dash trek-dash-shell">
{demoMode && <DemoBanner />}
<div className="trek-dash-scroll">
<MobileTopBar />
@@ -404,7 +204,8 @@ export default function DashboardPage(): React.ReactElement {
onClose={() => setCopyTrip(null)}
/>
)}
</div>
</div>
</>
)
}
+13 -57
View File
@@ -1,71 +1,28 @@
import React, { useEffect, useState } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { useTripStore } from '../store/tripStore'
import { tripRepo } from '../repo/tripRepo'
import { placeRepo } from '../repo/placeRepo'
import Navbar from '../components/Layout/Navbar'
import React from 'react'
import { Link } from 'react-router-dom'
import PageShell from '../components/Layout/PageShell'
import { PageSpinner } from '../components/shared/Spinner'
import FileManager from '../components/Files/FileManager'
import { ArrowLeft } from 'lucide-react'
import { useTranslation } from '../i18n'
import type { Trip, Place, TripFile } from '../types'
import { useFiles } from './files/useFiles'
export default function FilesPage(): React.ReactElement {
const { t } = useTranslation()
const { id: tripId } = useParams<{ id: string }>()
const navigate = useNavigate()
const tripStore = useTripStore()
const [trip, setTrip] = useState<Trip | null>(null)
const [places, setPlaces] = useState<Place[]>([])
const [files, setFiles] = useState<TripFile[]>([])
const [isLoading, setIsLoading] = useState<boolean>(true)
useEffect(() => {
loadData()
}, [tripId])
const loadData = async (): Promise<void> => {
setIsLoading(true)
try {
const [tripData, placesData] = await Promise.all([
tripRepo.get(tripId),
placeRepo.list(tripId),
])
setTrip(tripData.trip)
setPlaces(placesData.places)
await tripStore.loadFiles(tripId)
} catch (err: unknown) {
navigate('/dashboard')
} finally {
setIsLoading(false)
}
}
useEffect(() => {
setFiles(tripStore.files)
}, [tripStore.files])
const handleUpload = async (formData: FormData): Promise<void> => {
await tripStore.addFile(tripId, formData)
}
const handleDelete = async (fileId: number): Promise<void> => {
await tripStore.deleteFile(tripId, fileId)
}
// Page = wiring container: trip/places load, file sync + handlers live in the hook.
const { tripId, navigate, trip, places, files, isLoading, handleUpload, handleDelete } = useFiles()
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-700 rounded-full animate-spin"></div>
</div>
<PageSpinner
wrapperClassName="min-h-screen flex items-center justify-center bg-slate-50"
className="w-10 h-10 border-4 border-slate-200 border-t-slate-700"
/>
)
}
return (
<div className="min-h-screen bg-slate-50">
<Navbar tripTitle={trip?.name} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
<div style={{ paddingTop: 'var(--nav-h)' }}>
<PageShell className="bg-slate-50" navbar={{ tripTitle: trip?.name, tripId, showBack: true, onBack: () => navigate(`/trips/${tripId}`) }}>
<div className="max-w-5xl mx-auto px-4 py-6">
<div className="flex items-center gap-3 mb-6">
<Link
@@ -92,7 +49,6 @@ export default function FilesPage(): React.ReactElement {
tripId={tripId}
/>
</div>
</div>
</div>
</PageShell>
)
}
+4 -33
View File
@@ -1,8 +1,7 @@
import React, { useState, useEffect, FormEvent, ChangeEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import React, { ChangeEvent } from 'react'
import { Mail, ArrowLeft, CheckCircle2, Terminal } from 'lucide-react'
import { useTranslation } from '../i18n'
import { authApi } from '../api/client'
import { useForgotPassword } from './forgotPassword/useForgotPassword'
const inputBase: React.CSSProperties = {
width: '100%', padding: '11px 12px 11px 38px', borderRadius: 12,
@@ -13,36 +12,8 @@ const inputBase: React.CSSProperties = {
const ForgotPasswordPage: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [submitted, setSubmitted] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [smtpConfigured, setSmtpConfigured] = useState<boolean | null>(null)
useEffect(() => {
// Probe whether SMTP is configured so we can warn the user up-front
// that the link will land in the server console instead of their
// inbox. Null while pending — hint is hidden until we know.
authApi.getAppConfig?.()
.then((cfg: any) => {
const hasEmail = !!cfg?.available_channels?.email
setSmtpConfigured(hasEmail)
})
.catch(() => setSmtpConfigured(null))
}, [])
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (isLoading) return
setIsLoading(true)
try {
await authApi.forgotPassword({ email: email.trim() })
} catch {
// Enumeration-safe: success UX regardless of server outcome.
}
setSubmitted(true)
setIsLoading(false)
}
// Page = wiring container: form state, the SMTP probe and submit live in the hook.
const { navigate, email, setEmail, submitted, isLoading, smtpConfigured, handleSubmit } = useForgotPassword()
return (
<div style={{
+15 -46
View File
@@ -1,53 +1,23 @@
import React, { useEffect, useRef, useState } from 'react'
import React from 'react'
import { Bell, CheckCheck, Trash2 } from 'lucide-react'
import { useTranslation } from '../i18n'
import { useInAppNotificationStore } from '../store/inAppNotificationStore.ts'
import { useSettingsStore } from '../store/settingsStore'
import Navbar from '../components/Layout/Navbar'
import PageShell from '../components/Layout/PageShell'
import { Spinner } from '../components/shared/Spinner'
import InAppNotificationItem from '../components/Notifications/InAppNotificationItem.tsx'
import { useInAppNotifications } from './inAppNotifications/useInAppNotifications'
export default function InAppNotificationsPage(): React.ReactElement {
const { t } = useTranslation()
const { settings } = useSettingsStore()
const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const { notifications, unreadCount, total, isLoading, hasMore, fetchNotifications, markAllRead, deleteAll } = useInAppNotificationStore()
const [unreadOnly, setUnreadOnly] = useState(false)
const loaderRef = useRef<HTMLDivElement>(null)
useEffect(() => {
fetchNotifications(true)
}, [])
// Reload when filter changes
useEffect(() => {
// We need to fetch with the unreadOnly filter — re-fetch from scratch
// The store fetchNotifications doesn't take a filter param directly,
// so we use the API directly for filtered view via a side channel.
// For now, reset and fetch — store always loads all, filter is client-side.
fetchNotifications(true)
}, [unreadOnly])
// Infinite scroll
useEffect(() => {
if (!loaderRef.current) return
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && hasMore && !isLoading) {
fetchNotifications(false)
}
}, { threshold: 0.1 })
observer.observe(loaderRef.current)
return () => observer.disconnect()
}, [hasMore, isLoading])
const displayed = unreadOnly ? notifications.filter(n => !n.is_read) : notifications
// Page = wiring container: store, filter, fetch + infinite scroll live in the hook.
const {
notifications, unreadCount, total, isLoading, hasMore,
unreadOnly, setUnreadOnly, loaderRef, displayed,
markAllRead, deleteAll,
} = useInAppNotifications()
return (
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
<Navbar />
<div style={{ paddingTop: 'var(--nav-h)' }}>
<div className="max-w-2xl mx-auto px-4 py-8">
<PageShell background="var(--bg-primary)">
<div className="max-w-2xl mx-auto px-4 py-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
@@ -122,7 +92,7 @@ export default function InAppNotificationsPage(): React.ReactElement {
>
{isLoading && displayed.length === 0 ? (
<div className="flex items-center justify-center py-16">
<div className="w-6 h-6 border-2 border-slate-200 border-t-current rounded-full animate-spin" />
<Spinner className="w-6 h-6 border-2 border-slate-200 border-t-current" />
</div>
) : displayed.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center gap-3">
@@ -139,12 +109,11 @@ export default function InAppNotificationsPage(): React.ReactElement {
{/* Infinite scroll trigger */}
{hasMore && (
<div ref={loaderRef} className="flex items-center justify-center py-4">
{isLoading && <div className="w-5 h-5 border-2 border-slate-200 border-t-current rounded-full animate-spin" />}
{isLoading && <Spinner className="w-5 h-5 border-2 border-slate-200 border-t-current" />}
</div>
)}
</div>
</div>
</div>
</div>
</PageShell>
)
}
+17 -254
View File
@@ -32,6 +32,7 @@ import { useIsMobile } from '../hooks/useIsMobile'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
import { getApiErrorMessage } from '../types'
import { useJourneyDetail } from './journeyDetail/useJourneyDetail'
const GRADIENTS = [
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
@@ -88,260 +89,22 @@ function photoUrl(p: { photo_id: number }, size: 'thumbnail' | 'original' = 'thu
}
export default function JourneyDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
const toast = useToast()
const { t, locale } = useTranslation()
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto } = useJourneyStore()
const mapRef = useRef<JourneyMapHandle>(null)
const fullMapRef = useRef<JourneyMapHandle>(null)
const [activeLocationId, setActiveLocationId] = useState<string | null>(null)
const isMobile = useIsMobile()
// Role-based permissions (server-provided via my_role). Fall back to
// "owner" when the field isn't present yet (legacy responses) so behavior
// matches the pre-permissions era.
const myRole = (current as any)?.my_role ?? 'owner'
const canEditEntries = myRole === 'owner' || myRole === 'editor'
const canEditJourney = myRole === 'owner'
const [view, setView] = useState<'timeline' | 'gallery'>('timeline')
const [activeEntryId, setActiveEntryId] = useState<string | null>(null)
const feedRef = useRef<HTMLDivElement>(null)
const [viewingEntry, setViewingEntry] = useState<JourneyEntry | null>(null)
const [editingEntry, setEditingEntry] = useState<JourneyEntry | null>(null)
const [lightbox, setLightbox] = useState<{ photos: { id: number; src: string; caption?: string | null; provider?: string; asset_id?: string | null; owner_id?: number | null }[]; index: number } | null>(null)
const [deleteTarget, setDeleteTarget] = useState<JourneyEntry | null>(null)
const [showInvite, setShowInvite] = useState(false)
const [showAddTrip, setShowAddTrip] = useState(false)
const [searchParams, setSearchParams] = useSearchParams()
// The bottom-nav "+" starts a new entry via ?create=entry.
useEffect(() => {
if (searchParams.get('create') === 'entry' && current && canEditEntries) {
const today = new Date().toISOString().slice(0, 10)
setEditingEntry({ id: 0, journey_id: current.id, author_id: 0, type: 'entry', entry_date: today, visibility: 'private', sort_order: 0, photos: [], created_at: 0, updated_at: 0 } as JourneyEntry)
setSearchParams(p => { p.delete('create'); return p }, { replace: true })
}
}, [searchParams, current, canEditEntries])
const [unlinkTrip, setUnlinkTrip] = useState<{ trip_id: number; title: string } | null>(null)
const [showSettings, setShowSettings] = useState(false)
const [hideSkeletons, setHideSkeletons] = useState(false)
useEffect(() => {
if (id) loadJourney(Number(id)).catch(() => {})
}, [id])
useEffect(() => {
if (current?.hide_skeletons !== undefined) setHideSkeletons(current.hide_skeletons)
}, [current?.hide_skeletons])
useEffect(() => {
if (notFound) {
toast.error(t('journey.notFound'))
navigate('/journey')
}
}, [notFound])
// WebSocket real-time updates
useEffect(() => {
if (!id) return
const journeyId = Number(id)
const handler = (event: Record<string, unknown>) => {
const type = event.type as string
if (!type?.startsWith('journey:')) return
if (event.journeyId !== journeyId) return
// reload journey data on any change from other contributors
loadJourney(journeyId)
}
addListener(handler)
return () => removeListener(handler)
}, [id])
// scroll sync with map — the sticky map on the right follows whichever
// entry the user is currently reading in the feed on the left. We use
// scroll position (not IntersectionObserver) because short text-only
// entries pass through any IO band too quickly to reliably register.
const rafRef = useRef<number | null>(null)
const scrollCleanupRef = useRef<(() => void) | null>(null)
// Suppress scroll-sync updates while a programmatic smooth-scroll is
// running (triggered by a marker click). The scroll-progress reference
// line doesn't align with `scrollIntoView({ block: 'center' })`, so the
// sync would otherwise pick random entries as the scroll animates past
// them and end up nowhere near the clicked marker.
const suppressScrollSyncRef = useRef(false)
const suppressTimerRef = useRef<number | null>(null)
const setupScrollSync = useCallback(() => {
scrollCleanupRef.current?.()
const feed = feedRef.current
if (!feed) return
const commitWinner = () => {
if (suppressScrollSyncRef.current) return
const nodes = document.querySelectorAll('[data-entry-id]')
if (nodes.length === 0) return
const feedRect = feed.getBoundingClientRect()
// Reference line tracks scroll progress — at the top of the feed
// it sits at the top edge; at the bottom it sits at the bottom
// edge. This keeps every entry passing through the line exactly
// once even when they're too short to cross a static line before
// the feed runs out of scroll.
const maxScroll = feed.scrollHeight - feed.clientHeight
const progress = maxScroll > 0 ? feed.scrollTop / maxScroll : 0
const referenceY = feedRect.top + feedRect.height * progress
let lastPast: { id: string; top: number } | null = null
let firstAhead: { id: string; top: number } | null = null
nodes.forEach(el => {
const entryId = el.getAttribute('data-entry-id')
if (!entryId) return
const top = el.getBoundingClientRect().top
if (top <= referenceY) {
if (!lastPast || top > lastPast.top) lastPast = { id: entryId, top }
} else {
if (!firstAhead || top < firstAhead.top) firstAhead = { id: entryId, top }
}
})
const winner = lastPast || firstAhead
if (winner) {
setActiveEntryId(winner.id)
if (locatedEntryIdsRef.current.has(winner.id)) {
mapRef.current?.highlightMarker(winner.id)
}
}
}
const onScroll = () => {
if (rafRef.current != null) return
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null
commitWinner()
})
}
feed.addEventListener('scroll', onScroll, { passive: true })
window.addEventListener('scroll', onScroll, { passive: true })
// prime once so the map syncs on initial load
commitWinner()
scrollCleanupRef.current = () => {
feed.removeEventListener('scroll', onScroll)
window.removeEventListener('scroll', onScroll)
if (rafRef.current != null) {
window.cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}
}, [])
useEffect(() => {
if (current?.entries?.length) {
const t = window.setTimeout(setupScrollSync, 300)
return () => {
window.clearTimeout(t)
scrollCleanupRef.current?.()
}
}
return () => scrollCleanupRef.current?.()
}, [current?.entries, setupScrollSync])
const handleMarkerClick = useCallback((entryId: string) => {
const el = document.querySelector(`[data-entry-id="${entryId}"]`)
if (!el) return
// Commit the choice immediately so the highlighted marker stays pinned
// to the clicked entry even while smooth-scroll passes over others.
suppressScrollSyncRef.current = true
setActiveEntryId(entryId)
mapRef.current?.highlightMarker(entryId)
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
if (suppressTimerRef.current != null) window.clearTimeout(suppressTimerRef.current)
// Smooth scroll typically finishes within ~500ms; 750ms gives a safety
// buffer so the sync doesn't snap back to the wrong entry on the very
// last frame.
suppressTimerRef.current = window.setTimeout(() => {
suppressScrollSyncRef.current = false
suppressTimerRef.current = null
}, 750)
}, [])
useEffect(() => () => {
if (suppressTimerRef.current != null) window.clearTimeout(suppressTimerRef.current)
}, [])
const handleLocationClick = useCallback((id: string) => {
setActiveLocationId(id)
}, [])
useEffect(() => {
// give the sidebar map a chance to recalc its size when the view switches
// (feed column width can shift slightly if the gallery vs timeline
// renders with a different scrollbar state).
requestAnimationFrame(() => mapRef.current?.invalidateSize())
}, [view])
// On desktop we run a two-pane layout where only the feed column scrolls;
// the body must not scroll underneath it. Restore on unmount.
useEffect(() => {
if (isMobile) return
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = prev }
}, [isMobile])
// Map only shows real journal entries — skeletons are trip-derived
// suggestions, not something the user actually journaled at that spot.
const mapEntries = useMemo(
() => (current?.entries || []).filter(e =>
e.location_lat && e.location_lng &&
e.title !== 'Gallery' &&
e.title !== '[Trip Photos]' &&
e.type !== 'skeleton'
),
[current?.entries]
)
const sidebarMapItems = useMemo(() => {
const allDates = [...new Set(
(current?.entries || [])
.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]')
.map(e => e.entry_date)
.sort()
)]
const sorted = [...mapEntries].sort((a, b) => a.entry_date.localeCompare(b.entry_date))
const dayCounters = new Map<string, number>()
return sorted.map(e => {
const dayIdx = allDates.indexOf(e.entry_date)
const dayLabel = (dayCounters.get(e.entry_date) ?? 0) + 1
dayCounters.set(e.entry_date, dayLabel)
return {
id: String(e.id),
lat: e.location_lat!,
lng: e.location_lng!,
title: e.title || '',
location_name: e.location_name || '',
mood: e.mood,
created_at: e.entry_date,
entry_date: e.entry_date,
dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length],
dayLabel,
}
})
}, [mapEntries, current?.entries])
const locatedEntryIdsRef = useRef(new Set<string>())
useEffect(() => {
locatedEntryIdsRef.current = new Set(sidebarMapItems.map(m => m.id))
}, [sidebarMapItems])
const tripDates = useMemo(() => {
const dates = new Set<string>()
if (!current?.trips) return dates
for (const trip of current.trips) {
if (!trip.start_date || !trip.end_date) continue
const start = new Date(trip.start_date + 'T00:00:00')
const end = new Date(trip.end_date + 'T00:00:00')
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
dates.add(d.toISOString().split('T')[0])
}
}
return dates
}, [current?.trips])
// Page = wiring container: load + live sync, view state, dialogs, the
// scroll-synced map and the map/trip-date derivations live in the hook.
const {
id, navigate, toast, t, locale,
current, loading,
canEditEntries, canEditJourney, myRole,
view, setView, activeEntryId, setActiveEntryId, feedRef,
viewingEntry, setViewingEntry, editingEntry, setEditingEntry,
lightbox, setLightbox, deleteTarget, setDeleteTarget,
showInvite, setShowInvite, showAddTrip, setShowAddTrip,
unlinkTrip, setUnlinkTrip, showSettings, setShowSettings,
hideSkeletons, setHideSkeletons,
mapRef, fullMapRef, activeLocationId, handleMarkerClick, handleLocationClick,
mapEntries, sidebarMapItems, tripDates, isMobile,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
} = useJourneyDetail()
if (loading || !current) {
return (
+34
View File
@@ -194,6 +194,40 @@ describe('JourneyPage', () => {
});
});
// FE-PAGE-JOURNEY-007b — XSS regression: the suggestion banner interpolates
// a user-controlled trip title into an HTML template that is later passed to
// dangerouslySetInnerHTML. The sanitiser in @trek/shared must drop any script
// payload, otherwise renaming a trip is a one-click XSS for anyone visiting
// the Journey page.
it('FE-PAGE-JOURNEY-007b: sanitises script payloads in suggestion title', async () => {
const malicious = {
id: 1337,
title: '<img src=x onerror=alert(1)><script>window.__pwned=true</script>',
start_date: '2026-03-01',
end_date: '2026-04-01',
place_count: 3,
};
server.use(
http.get('/api/journeys', () =>
HttpResponse.json({ journeys: [] })
),
http.get('/api/journeys/suggestions', () =>
HttpResponse.json({ trips: [malicious] })
),
);
render(<JourneyPage />);
await waitFor(() => {
expect(screen.getByText(/Trip just ended/i)).toBeInTheDocument();
});
// The script tag must not survive the sanitiser anywhere in the rendered DOM.
expect(document.querySelector('script')).toBeNull();
expect(document.querySelector('img[onerror]')).toBeNull();
// And the side effect would only fire if onerror executed.
expect((window as unknown as { __pwned?: boolean }).__pwned).toBeUndefined();
});
// FE-PAGE-JOURNEY-008
it('FE-PAGE-JOURNEY-008: shows active journey hero when active journey exists', async () => {
const active = buildJourneyListItem({ id: 10, title: 'Active Trip', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
+16 -93
View File
@@ -1,16 +1,12 @@
import { useEffect, useState, useMemo, useRef } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useJourneyStore } from '../store/journeyStore'
import { journeyApi } from '../api/client'
import Navbar from '../components/Layout/Navbar'
import { useToast } from '../components/shared/Toast'
import { useTranslation } from '../i18n'
import PageShell from '../components/Layout/PageShell'
import { useTranslation, TransHtml } from '../i18n'
import {
Plus, Search, Sparkles, Calendar, MapPin, BookOpen, Camera,
Check, X, ChevronRight, RefreshCw, Users,
} from 'lucide-react'
import type { Journey } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
import { useJourney } from './journey/useJourney'
const GRADIENTS = [
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
@@ -35,92 +31,20 @@ function timeAgo(timestamp: number, t: (k: string, p?: any) => string): string {
}
export default function JourneyPage() {
const navigate = useNavigate()
const toast = useToast()
const { t } = useTranslation()
const { journeys, loading, loadJourneys, createJourney } = useJourneyStore()
const [showCreate, setShowCreate] = useState(false)
const [newTitle, setNewTitle] = useState('')
const [availableTrips, setAvailableTrips] = useState<any[]>([])
const [selectedTripIds, setSelectedTripIds] = useState<Set<number>>(new Set())
const [searchOpen, setSearchOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const searchInputRef = useRef<HTMLInputElement>(null)
// suggestion
const [suggestions, setSuggestions] = useState<any[]>([])
const [dismissedSuggestions, setDismissedSuggestions] = useState<Set<number>>(new Set())
const [searchParams, setSearchParams] = useSearchParams()
useEffect(() => {
loadJourneys()
journeyApi.suggestions().then(d => setSuggestions(d.trips || [])).catch(() => {})
}, [])
// The bottom-nav "+" opens the new-journey modal via ?create=1.
useEffect(() => {
if (searchParams.get('create') === '1') {
openCreateModal()
setSearchParams(p => { p.delete('create'); return p }, { replace: true })
}
}, [searchParams])
const activeSuggestion = suggestions.find(s => !dismissedSuggestions.has(s.id))
const activeJourney = useMemo(() => {
if (searchQuery.trim()) return null
return journeys.find(j => {
const j2 = j as any
return computeJourneyLifecycle(j.status, j2.trip_date_min, j2.trip_date_max) === 'live'
}) || null
}, [journeys, searchQuery])
const filteredJourneys = useMemo(() => {
const q = searchQuery.trim().toLowerCase()
if (!q) return journeys.filter(j => j.id !== activeJourney?.id)
return journeys.filter(j => {
const inTitle = j.title.toLowerCase().includes(q)
const inSubtitle = j.subtitle?.toLowerCase().includes(q) ?? false
return inTitle || inSubtitle
})
}, [journeys, activeJourney, searchQuery])
const openCreateModal = async (preSelectedTripId?: number) => {
setShowCreate(true)
setNewTitle('')
const initial = new Set<number>()
if (preSelectedTripId) initial.add(preSelectedTripId)
setSelectedTripIds(initial)
try {
const data = await journeyApi.availableTrips()
setAvailableTrips(data.trips || [])
} catch {}
}
const handleCreate = async () => {
if (!newTitle.trim()) return
try {
const j = await createJourney({
title: newTitle.trim(),
trip_ids: [...selectedTripIds],
})
setShowCreate(false)
navigate(`/journey/${j.id}`)
} catch {
toast.error(t('journey.createError'))
}
}
const totalPlaces = useMemo(() => {
return availableTrips.filter(t => selectedTripIds.has(t.id)).reduce((sum: number, t: any) => sum + (t.place_count || 0), 0)
}, [availableTrips, selectedTripIds])
// Page = wiring container: store load, create modal, search + suggestions in the hook.
const {
navigate, journeys, loading,
showCreate, setShowCreate, newTitle, setNewTitle,
availableTrips, selectedTripIds, setSelectedTripIds,
searchOpen, setSearchOpen, searchQuery, setSearchQuery, searchInputRef,
activeSuggestion, setDismissedSuggestions,
activeJourney, filteredJourneys,
openCreateModal, handleCreate, totalPlaces,
} = useJourney()
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<Navbar />
<div style={{ paddingTop: 'var(--nav-h, 56px)' }}>
<PageShell className="bg-zinc-50 dark:bg-zinc-950" navOffset="var(--nav-h, 56px)">
<div className="max-w-[1440px] mx-auto">
{/* Header — mobile */}
@@ -213,7 +137,7 @@ export default function JourneyPage() {
<div>
<div className="text-[10px] font-semibold tracking-[0.12em] uppercase opacity-70">{t("journey.frontpage.suggestionLabel")}</div>
<div className="text-[13px] mt-0.5">
<span dangerouslySetInnerHTML={{ __html: t('journey.frontpage.suggestionText', { title: activeSuggestion.title }) }} />
<TransHtml html="journey.frontpage.suggestionText" params={{ title: activeSuggestion.title }} />
</div>
</div>
</div>
@@ -355,7 +279,6 @@ export default function JourneyPage() {
)}
</div>
</div>
</div>
{/* Create Modal */}
{showCreate && (
@@ -463,7 +386,7 @@ export default function JourneyPage() {
</div>
</div>
)}
</div>
</PageShell>
)
}
+11 -131
View File
@@ -1,6 +1,3 @@
import { useEffect, useState, useMemo, useRef, useCallback } from 'react'
import { useParams } from 'react-router-dom'
import { journeyApi } from '../api/client'
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
import { useSettingsStore } from '../store/settingsStore'
import {
@@ -10,51 +7,13 @@ import {
ThumbsUp, ThumbsDown,
} from 'lucide-react'
import JourneyMap from '../components/Journey/JourneyMap'
import type { JourneyMapHandle } from '../components/Journey/JourneyMap'
import JournalBody from '../components/Journey/JournalBody'
import PhotoLightbox from '../components/Journey/PhotoLightbox'
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import MobileEntryView from '../components/Journey/MobileEntryView'
import { useIsMobile } from '../hooks/useIsMobile'
import { formatLocationName } from '../utils/formatters'
import { DAY_COLORS } from '../components/Journey/dayColors'
interface PublicEntry {
id: number
title?: string | null
story?: string | null
entry_date: string
entry_time?: string | null
location_name?: string | null
location_lat?: number | null
location_lng?: number | null
mood?: string | null
weather?: string | null
pros_cons?: { pros: string[]; cons: string[] } | null
photos: PublicPhoto[]
}
interface PublicPhoto {
id: number
entry_id: number
photo_id: number
provider?: string
asset_id?: string | null
owner_id?: number | null
file_path?: string | null
caption?: string | null
}
interface PublicGalleryPhoto {
id: number
journey_id: number
photo_id: number
provider?: string
asset_id?: string | null
owner_id?: number | null
file_path?: string | null
caption?: string | null
}
import { useJourneyPublic } from './journeyPublic/useJourneyPublic'
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' },
@@ -85,97 +44,18 @@ function formatDate(d: string, locale?: string): { weekday: string; month: strin
}
}
function groupByDate(entries: PublicEntry[]): Map<string, PublicEntry[]> {
const groups = new Map<string, PublicEntry[]>()
for (const e of entries) {
const d = e.entry_date
if (!groups.has(d)) groups.set(d, [])
groups.get(d)!.push(e)
}
return groups
}
export default function JourneyPublicPage() {
const { token } = useParams()
const [data, setData] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const isMobile = useIsMobile()
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
const [lightbox, setLightbox] = useState<{ photos: { id: string; src: string; caption?: string | null }[]; index: number } | null>(null)
const { t } = useTranslation()
const [showLangPicker, setShowLangPicker] = useState(false)
const locale = useSettingsStore(s => s.settings.language) || 'en'
const mapRef = useRef<JourneyMapHandle>(null)
const [activeEntryId, setActiveEntryId] = useState<string | null>(null)
const [viewingEntry, setViewingEntry] = useState<PublicEntry | null>(null)
const handleMarkerClick = useCallback((entryId: string) => {
setActiveEntryId(entryId)
mapRef.current?.highlightMarker(entryId)
document.querySelector(`[data-entry-id="${entryId}"]`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, [])
useEffect(() => {
if (!token) return
journeyApi.getPublicJourney(token)
.then(d => setData(d))
.catch(() => setError(true))
.finally(() => setLoading(false))
}, [token])
const entries = (data?.entries || []) as PublicEntry[]
const gallery = (data?.gallery || []) as PublicGalleryPhoto[]
const perms = data?.permissions || {}
const journey = data?.journey || {}
const stats = data?.stats || {}
const timelineEntries = useMemo(() => entries, [entries])
const groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries])
const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries])
const mapEntries = useMemo(
() => timelineEntries.filter(e => e.location_lat && e.location_lng),
[timelineEntries],
)
const allPhotos = gallery
// Map entries with day color/label for colored markers.
// dayIdx is derived from sortedDates (ALL timeline dates) so marker colors
// stay in sync with the timeline day headers even when some days have no locations.
const sidebarMapItems = useMemo(() => {
const counters = new Map<string, number>()
return mapEntries.map(e => {
const dayIdx = sortedDates.indexOf(e.entry_date)
const dayLabel = (counters.get(e.entry_date) ?? 0) + 1
counters.set(e.entry_date, dayLabel)
return {
id: String(e.id),
lat: e.location_lat!,
lng: e.location_lng!,
title: e.title || '',
mood: e.mood,
created_at: e.entry_date,
entry_date: e.entry_date,
dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length],
dayLabel,
}
})
}, [mapEntries, sortedDates])
// Two-column desktop layout: timeline feed left + sticky map right
const desktopTwoColumn = !isMobile && perms.share_timeline && perms.share_map
// Set default view based on permissions
useEffect(() => {
if (!perms.share_timeline && perms.share_gallery) setView('gallery')
else if (!perms.share_timeline && !perms.share_gallery && perms.share_map) setView('map')
}, [perms])
// When switching to desktop two-column, 'map' standalone tab no longer exists
useEffect(() => {
if (desktopTwoColumn && view === 'map') setView('timeline')
}, [desktopTwoColumn, view])
// Page = wiring container: the share fetch, view state and all timeline/map
// derivations live in the hook; the render helpers below stay next to the JSX.
const {
token, data, loading, error, isMobile, locale,
view, setView, lightbox, setLightbox, showLangPicker, setShowLangPicker,
mapRef, activeEntryId, setActiveEntryId, viewingEntry, setViewingEntry, handleMarkerClick,
perms, journey, stats,
timelineEntries, groupedEntries, sortedDates, sidebarMapItems, allPhotos,
desktopTwoColumn,
} = useJourneyPublic()
if (loading) {
return (
+15 -253
View File
@@ -1,260 +1,22 @@
import React, { useState, useEffect, useMemo, useRef } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { SUPPORTED_LANGUAGES, useTranslation, detectBrowserLanguage } from '../i18n'
import { authApi, configApi } from '../api/client'
import { hasStoredLanguage } from '../store/settingsStore'
import { getApiErrorMessage } from '../types'
import React from 'react'
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound, ChevronDown } from 'lucide-react'
interface AppConfig {
has_users: boolean
allow_registration: boolean
setup_complete: boolean
demo_mode: boolean
oidc_configured: boolean
oidc_display_name?: string
oidc_only_mode: boolean
password_login: boolean
password_registration: boolean
oidc_login: boolean
oidc_registration: boolean
env_override_oidc_only: boolean
}
import { useLogin } from './login/useLogin'
export default function LoginPage(): React.ReactElement {
const { t, language } = useTranslation()
const [mode, setMode] = useState<'login' | 'register'>('login')
const [username, setUsername] = useState<string>('')
const [email, setEmail] = useState<string>('')
const [password, setPassword] = useState<string>('')
const [showPassword, setShowPassword] = useState<boolean>(false)
const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<string>('')
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
const [inviteToken, setInviteToken] = useState<string>('')
const [inviteValid, setInviteValid] = useState<boolean>(false)
const exchangeInitiated = useRef(false)
const [langDropdownOpen, setLangDropdownOpen] = useState<boolean>(false)
const { login, register, demoLogin, completeMfaLogin, loadUser } = useAuthStore()
const { setLanguageLocal, setLanguageTransient } = useSettingsStore()
const navigate = useNavigate()
const location = useLocation()
const noRedirect = !!(location.state as { noRedirect?: boolean } | null)?.noRedirect
const redirectTarget = useMemo(() => {
const params = new URLSearchParams(window.location.search)
const redirect = params.get('redirect')
// Only allow relative paths starting with / to prevent open redirect attacks
if (redirect && redirect.startsWith('/') && !redirect.startsWith('//') && !redirect.startsWith('/\\')) {
return redirect
}
return '/dashboard'
}, [])
useEffect(() => {
if (redirectTarget !== '/dashboard') {
sessionStorage.setItem('oidc_redirect', redirectTarget)
}
}, [redirectTarget])
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const invite = params.get('invite')
const oidcCode = params.get('oidc_code')
const oidcError = params.get('oidc_error')
if (invite) {
setInviteToken(invite)
setMode('register')
authApi.validateInvite(invite).then(() => {
setInviteValid(true)
}).catch(() => {
setError(t('login.invalidInviteLink'))
})
window.history.replaceState({}, '', window.location.pathname)
}
if (oidcCode) {
if (exchangeInitiated.current) return
exchangeInitiated.current = true
setIsLoading(true)
fetch('/api/auth/oidc/exchange?code=' + encodeURIComponent(oidcCode), { credentials: 'include' })
.then(r => r.json())
.then(async data => {
window.history.replaceState({}, '', '/login')
if (data.token) {
await loadUser()
const savedRedirect = sessionStorage.getItem('oidc_redirect') || '/dashboard'
sessionStorage.removeItem('oidc_redirect')
navigate(savedRedirect, { replace: true })
} else {
setError(data.error || t('login.oidcFailed'))
}
})
.catch(() => {
window.history.replaceState({}, '', '/login')
setError(t('login.oidcFailed'))
})
.finally(() => setIsLoading(false))
return
}
if (oidcError) {
const errorMessages: Record<string, string> = {
registration_disabled: t('login.oidc.registrationDisabled'),
no_email: t('login.oidc.noEmail'),
token_failed: t('login.oidc.tokenFailed'),
invalid_state: t('login.oidc.invalidState'),
}
setError(errorMessages[oidcError] || oidcError)
sessionStorage.removeItem('oidc_redirect')
window.history.replaceState({}, '', '/login')
return
}
const CONFIG_CACHE_KEY = 'trek_app_config_cache'
authApi.getAppConfig?.()
.then((config: AppConfig) => {
try { localStorage.setItem(CONFIG_CACHE_KEY, JSON.stringify(config)) } catch { /* ignore quota errors */ }
return { config, fromCache: false }
})
.catch(() => {
try {
const raw = localStorage.getItem(CONFIG_CACHE_KEY)
return raw ? { config: JSON.parse(raw) as AppConfig, fromCache: true } : { config: null as AppConfig | null, fromCache: false }
} catch { return { config: null as AppConfig | null, fromCache: false } }
})
.then(({ config, fromCache }) => {
if (config) {
setAppConfig(config)
if (!config.has_users) setMode('register')
// Skip auto-redirect when config is from cache — network is unreliable
// and auto-redirecting to the IdP could loop if the proxy changed.
if (!fromCache && !config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) {
window.location.href = '/api/auth/oidc/login'
}
}
})
}, [navigate, t, noRedirect])
// Language detection chain (runs once on mount, only if user has no saved preference):
// 1. localStorage → already in store initial state, skip
// 2. Browser/OS language (navigator.languages)
// 3. Server default (DEFAULT_LANGUAGE env var)
// 4. 'en' → hardcoded fallback already in store
useEffect(() => {
if (hasStoredLanguage()) return
const detected = detectBrowserLanguage()
if (detected) {
setLanguageTransient(detected)
return
}
configApi.getPublicConfig()
.then(({ defaultLanguage }) => { if (defaultLanguage) setLanguageTransient(defaultLanguage) })
.catch((err) => console.warn('Failed to fetch default language config:', err))
}, [setLanguageTransient])
useEffect(() => {
if (!langDropdownOpen) return
const close = () => setLangDropdownOpen(false)
document.addEventListener('click', close)
return () => document.removeEventListener('click', close)
}, [langDropdownOpen])
const handleDemoLogin = async (): Promise<void> => {
setError('')
setIsLoading(true)
try {
await demoLogin()
setShowTakeoff(true)
setTimeout(() => navigate(redirectTarget), 2600)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('login.demoFailed'))
} finally {
setIsLoading(false)
}
}
const [showTakeoff, setShowTakeoff] = useState<boolean>(false)
const [mfaStep, setMfaStep] = useState(false)
const [mfaToken, setMfaToken] = useState('')
const [mfaCode, setMfaCode] = useState('')
const [passwordChangeStep, setPasswordChangeStep] = useState(false)
const [savedLoginPassword, setSavedLoginPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault()
setError('')
setIsLoading(true)
try {
if (passwordChangeStep) {
if (!newPassword) { setError(t('settings.passwordRequired')); setIsLoading(false); return }
if (newPassword.length < 8) { setError(t('settings.passwordTooShort')); setIsLoading(false); return }
if (newPassword !== confirmPassword) { setError(t('settings.passwordMismatch')); setIsLoading(false); return }
await authApi.changePassword({ current_password: savedLoginPassword, new_password: newPassword })
await loadUser({ silent: true })
setShowTakeoff(true)
setTimeout(() => navigate(redirectTarget), 2600)
return
}
if (mode === 'login' && mfaStep) {
if (!mfaCode.trim()) {
setError(t('login.mfaCodeRequired'))
setIsLoading(false)
return
}
const mfaResult = await completeMfaLogin(mfaToken, mfaCode)
if ('user' in mfaResult && mfaResult.user?.must_change_password) {
setSavedLoginPassword(password)
setPasswordChangeStep(true)
setIsLoading(false)
return
}
setShowTakeoff(true)
setTimeout(() => navigate(redirectTarget), 2600)
return
}
if (mode === 'register') {
if (!username.trim()) { setError(t('login.usernameRequired')); setIsLoading(false); return }
if (password.length < 8) { setError(t('login.passwordMinLength')); setIsLoading(false); return }
await register(username, email, password, inviteToken || undefined)
} else {
const result = await login(email, password)
if ('mfa_required' in result && result.mfa_required && 'mfa_token' in result) {
setMfaToken(result.mfa_token)
setMfaStep(true)
setMfaCode('')
setIsLoading(false)
return
}
if ('user' in result && result.user?.must_change_password) {
setSavedLoginPassword(password)
setPasswordChangeStep(true)
setIsLoading(false)
return
}
}
setShowTakeoff(true)
setTimeout(() => navigate(redirectTarget), 2600)
} catch (err: unknown) {
setError(getApiErrorMessage(err, t('login.error')))
setIsLoading(false)
}
}
const showRegisterOption = (appConfig?.password_registration || !appConfig?.has_users || inviteValid) && (appConfig?.setup_complete !== false || !appConfig?.has_users)
// In OIDC-only mode, show a minimal page that redirects directly to the IdP
const oidcOnly = !appConfig?.password_login && appConfig?.oidc_login && appConfig?.oidc_configured
// Page = wiring container: the whole auth surface lives in the useLogin hook.
const {
navigate,
mode, setMode,
username, setUsername, email, setEmail, password, setPassword, showPassword, setShowPassword,
isLoading, error, setError, appConfig, inviteToken,
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword,
noRedirect, showRegisterOption, oidcOnly,
handleDemoLogin, handleSubmit,
} = useLogin()
const inputBase: React.CSSProperties = {
width: '100%', padding: '11px 12px 11px 40px', border: '1px solid #e5e7eb',
+7 -139
View File
@@ -1,148 +1,16 @@
import React, { useEffect, useState } from 'react'
import { useAuthStore } from '../store/authStore'
import { oauthApi } from '../api/client'
import React from 'react'
import { SCOPE_GROUPS } from '../api/oauthScopes'
import { Lock, ShieldCheck, AlertTriangle, Loader2, LogIn } from 'lucide-react'
import { useTranslation } from '../i18n'
interface ValidateResult {
valid: boolean
error?: string
error_description?: string
client?: { name: string; allowed_scopes: string[] }
scopes?: string[]
consentRequired?: boolean
loginRequired?: boolean
scopeSelectable?: boolean
}
type PageState = 'loading' | 'login_required' | 'consent' | 'auto_approving' | 'error' | 'done'
import { useOAuthAuthorize } from './oauthAuthorize/useOAuthAuthorize'
export default function OAuthAuthorizePage(): React.ReactElement {
const { t } = useTranslation()
const { isAuthenticated, isLoading: authLoading, loadUser } = useAuthStore()
const [pageState, setPageState] = useState<PageState>('loading')
const [validation, setValidation] = useState<ValidateResult | null>(null)
const [submitting, setSubmitting] = useState(false)
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const [selectedScopes, setSelectedScopes] = useState<string[]>([])
const params = new URLSearchParams(window.location.search)
const clientId = params.get('client_id') || ''
const redirectUri = params.get('redirect_uri') || ''
const scope = params.get('scope') || ''
const state = params.get('state') || ''
const codeChallenge = params.get('code_challenge') || ''
const ccMethod = params.get('code_challenge_method') || ''
const resource = params.get('resource') || undefined
// Load auth state once, then validate
useEffect(() => {
loadUser({ silent: true }).catch(() => {})
}, [loadUser])
useEffect(() => {
if (authLoading) return
validateRequest()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [authLoading, isAuthenticated])
async function validateRequest() {
setPageState('loading')
try {
const result = await oauthApi.validate({
client_id: clientId,
redirect_uri: redirectUri,
scope,
state,
code_challenge: codeChallenge,
code_challenge_method: ccMethod,
response_type: 'code',
resource,
})
setValidation(result)
if (!result.valid) {
setPageState('error')
setErrorMsg(result.error_description || result.error || 'Invalid authorization request')
return
}
if (result.loginRequired) {
setPageState('login_required')
return
}
if (!result.consentRequired) {
// Consent already on record — auto-approve silently with the full validated scope
setPageState('auto_approving')
await submitConsent(true, result.scopes ?? [])
return
}
// Pre-select all scopes the client is requesting — user can deselect
setSelectedScopes(result.scopes ?? [])
setPageState('consent')
} catch (err: unknown) {
setPageState('error')
setErrorMsg('Failed to validate authorization request. Please try again.')
}
}
async function submitConsent(approved: boolean, scopes: string[] = selectedScopes) {
setSubmitting(true)
try {
const result = await oauthApi.authorize({
client_id: clientId,
redirect_uri: redirectUri,
// When approving, send only the scopes the user selected; deny uses original scope
scope: approved ? scopes.join(' ') : scope,
state,
code_challenge: codeChallenge,
code_challenge_method: ccMethod,
approved,
resource,
})
setPageState('done')
window.location.href = result.redirect
} catch {
setPageState('error')
setErrorMsg('Authorization failed. Please try again.')
setSubmitting(false)
}
}
function toggleScope(s: string) {
setSelectedScopes(prev =>
prev.includes(s) ? prev.filter(x => x !== s) : [...prev, s]
)
}
function toggleGroup(groupScopes: string[], allSelected: boolean) {
setSelectedScopes(prev =>
allSelected
? prev.filter(s => !groupScopes.includes(s))
: [...new Set([...prev, ...groupScopes])]
)
}
function handleLoginRedirect() {
const next = '/oauth/consent?' + params.toString() + window.location.hash
window.location.href = '/login?redirect=' + encodeURIComponent(next)
}
// Group requested scopes by their translated group name
const scopesByGroup = React.useMemo(() => {
const requested = validation?.scopes || []
const groups: Record<string, string[]> = {}
for (const s of requested) {
const keys = SCOPE_GROUPS[s]
const group = keys ? t(keys.groupKey) : 'Other'
if (!groups[group]) groups[group] = []
groups[group].push(s)
}
return groups
}, [validation, t])
// Page = wiring container: the validate→consent state machine lives in the hook.
const {
pageState, validation, submitting, errorMsg, selectedScopes, clientId,
scopesByGroup, submitConsent, toggleScope, toggleGroup, handleLoginRedirect,
} = useOAuthAuthorize()
// ---- Render states ----
+66
View File
@@ -0,0 +1,66 @@
# Page pattern: wiring container + data hook
Every page under `src/pages` follows the same shape: the exported `*Page`
component is a thin **wiring container** and all of its state, effects, data
loading and event handlers live in a co-located **`use<Page>()` hook**.
```
src/pages/
DashboardPage.tsx ← container: reads the hook, renders JSX
dashboard/
useDashboard.ts ← state, effects, API calls, handlers
dashboardModel.ts ← (optional) pure types + helpers, no React
```
## What goes where
**The hook (`use<Page>.ts`)** owns everything stateful:
- `useState` / `useReducer` / `useRef`
- `useEffect` / `useLayoutEffect`
- `useMemo` / `useCallback`
- store selectors, API calls, WebSocket listeners, handlers
- derived values
It returns a single object the page destructures.
**The page (`*Page.tsx`)** is presentation only:
- `const { ... } = use<Page>()`
- `useTranslation()` for `t`/`locale` (a context hook, not state — allowed)
- JSX, and `t`-dependent display arrays like the tab list
- presentational sub-components and pure helpers may live in the same file,
before or after the default export
```tsx
export default function DashboardPage() {
const { t } = useTranslation()
const { trips, isLoading, handleCreate } = useDashboard()
if (isLoading) return <Spinner />
return <Grid trips={trips} onCreate={handleCreate} />
}
```
## Why
- **Testable** — page tests render JSX; hook logic is isolated and mockable.
- **Readable** — the container reads top-to-bottom as "what the page shows".
- **Diffable** — logic changes touch the hook, layout changes touch the page.
## Notes
- A `<page>Model.ts` is optional — use it for pure types and helpers shared
between the hook and the page (no React imports). See `atlas/atlasModel.ts`
for a mutable-lookup-table example and `admin/adminModel.ts` for types only.
- The post-guard derivations that depend on a now-narrowed value (e.g. after
`if (!current) return`) may stay in the page next to the JSX that uses them.
- Keep the rendered JSX byte-identical when extracting — this is a refactor of
where logic lives, not a redesign.
## Enforcement
`npm run lint:pages` (`scripts/check-page-pattern.mjs`) scans each `*Page.tsx`
default-export body and fails if it calls `useState`, `useReducer`, `useEffect`,
`useLayoutEffect`, `useMemo`, `useCallback` or `useRef` directly. Move that logic
into the page's hook. Sub-components and helper hooks in the same file are not
flagged.
+13 -66
View File
@@ -1,80 +1,28 @@
import React, { useEffect, useState } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { useTripStore } from '../store/tripStore'
import { tripsApi, daysApi, placesApi } from '../api/client'
import Navbar from '../components/Layout/Navbar'
import React from 'react'
import { Link } from 'react-router-dom'
import PageShell from '../components/Layout/PageShell'
import { PageSpinner } from '../components/shared/Spinner'
import PhotoGallery from '../components/Photos/PhotoGallery'
import { ArrowLeft } from 'lucide-react'
import { useTranslation } from '../i18n'
import type { Trip, Day, Place, Photo } from '../types'
import { usePhotos } from './photos/usePhotos'
export default function PhotosPage(): React.ReactElement {
const { t } = useTranslation()
const { id: tripId } = useParams<{ id: string }>()
const navigate = useNavigate()
const tripStore = useTripStore()
const [trip, setTrip] = useState<Trip | null>(null)
const [days, setDays] = useState<Day[]>([])
const [places, setPlaces] = useState<Place[]>([])
const [photos, setPhotos] = useState<Photo[]>([])
const [isLoading, setIsLoading] = useState<boolean>(true)
useEffect(() => {
loadData()
}, [tripId])
const loadData = async (): Promise<void> => {
setIsLoading(true)
try {
const [tripData, daysData, placesData] = await Promise.all([
tripsApi.get(tripId),
daysApi.list(tripId),
placesApi.list(tripId),
])
setTrip(tripData.trip)
setDays(daysData.days)
setPlaces(placesData.places)
// Load photos
await tripStore.loadPhotos(tripId)
} catch (err: unknown) {
navigate('/dashboard')
} finally {
setIsLoading(false)
}
}
// Sync photos from store
useEffect(() => {
setPhotos(tripStore.photos)
}, [tripStore.photos])
const handleUpload = async (formData: FormData): Promise<void> => {
await tripStore.addPhoto(tripId, formData)
}
const handleDelete = async (photoId: number): Promise<void> => {
await tripStore.deletePhoto(tripId, photoId)
}
const handleUpdate = async (photoId: number, data: Record<string, string | number | null>): Promise<void> => {
await tripStore.updatePhoto(tripId, photoId, data)
}
// Page = wiring container: trip/days/places load, photo sync + handlers live in the hook.
const { tripId, navigate, trip, days, places, photos, isLoading, handleUpload, handleDelete, handleUpdate } = usePhotos()
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-700 rounded-full animate-spin"></div>
</div>
<PageSpinner
wrapperClassName="min-h-screen flex items-center justify-center bg-slate-50"
className="w-10 h-10 border-4 border-slate-200 border-t-slate-700"
/>
)
}
return (
<div className="min-h-screen bg-slate-50">
<Navbar tripTitle={trip?.name} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
<div style={{ paddingTop: 'var(--nav-h)' }}>
<PageShell className="bg-slate-50" navbar={{ tripTitle: trip?.name, tripId, showBack: true, onBack: () => navigate(`/trips/${tripId}`) }}>
<div className="max-w-7xl mx-auto px-4 py-6">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
@@ -104,7 +52,6 @@ export default function PhotosPage(): React.ReactElement {
tripId={tripId}
/>
</div>
</div>
</div>
</PageShell>
)
}
+9 -38
View File
@@ -1,46 +1,17 @@
import React, { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../store/authStore'
import React from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from '../i18n'
import { Map, Eye, EyeOff, Mail, Lock, User } from 'lucide-react'
import { useRegister } from './register/useRegister'
export default function RegisterPage(): React.ReactElement {
const { t } = useTranslation()
const [username, setUsername] = useState<string>('')
const [email, setEmail] = useState<string>('')
const [password, setPassword] = useState<string>('')
const [confirmPassword, setConfirmPassword] = useState<string>('')
const [showPassword, setShowPassword] = useState<boolean>(false)
const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<string>('')
const { register } = useAuthStore()
const navigate = useNavigate()
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault()
setError('')
if (password !== confirmPassword) {
setError(t('register.passwordMismatch'))
return
}
if (password.length < 8) {
setError(t('register.passwordTooShort'))
return
}
setIsLoading(true)
try {
await register(username, email, password)
navigate('/dashboard')
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('register.failed'))
} finally {
setIsLoading(false)
}
}
// Page = wiring container: form state, validation + register flow live in the hook.
const {
username, setUsername, email, setEmail, password, setPassword,
confirmPassword, setConfirmPassword, showPassword, setShowPassword,
isLoading, error, handleSubmit,
} = useRegister()
return (
<div className="min-h-screen flex">
+9 -48
View File
@@ -1,9 +1,7 @@
import React, { useState, useEffect, FormEvent, ChangeEvent } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import React, { ChangeEvent } from 'react'
import { Lock, KeyRound, CheckCircle2, AlertTriangle, Eye, EyeOff } from 'lucide-react'
import { useTranslation } from '../i18n'
import { authApi } from '../api/client'
import { getApiErrorMessage } from '../types'
import { useResetPassword } from './resetPassword/useResetPassword'
const inputBase: React.CSSProperties = {
width: '100%', padding: '11px 44px 11px 38px', borderRadius: 12,
@@ -14,50 +12,13 @@ const inputBase: React.CSSProperties = {
const ResetPasswordPage: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const [params] = useSearchParams()
const token = params.get('token') || ''
const [pw, setPw] = useState('')
const [pw2, setPw2] = useState('')
const [showPw, setShowPw] = useState(false)
const [mfaCode, setMfaCode] = useState('')
const [mfaRequired, setMfaRequired] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
if (!token) setError(t('login.resetPasswordInvalidLink'))
}, [token, t])
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (isLoading) return
setError('')
if (!token) return
if (pw.length < 8) { setError(t('login.passwordMinLength')); return }
if (pw !== pw2) { setError(t('login.passwordsDontMatch')); return }
setIsLoading(true)
try {
const res = await authApi.resetPassword({
token,
new_password: pw,
...(mfaRequired && mfaCode ? { mfa_code: mfaCode.trim() } : {}),
})
if (res.mfa_required) {
setMfaRequired(true)
setIsLoading(false)
return
}
if (res.success) {
setSuccess(true)
}
} catch (err) {
setError(getApiErrorMessage(err, t('login.resetPasswordFailed')))
}
setIsLoading(false)
}
// Page = wiring container: token, form state, validation + submit live in the hook.
const {
navigate, token,
pw, setPw, pw2, setPw2, showPw, setShowPw,
mfaCode, setMfaCode, mfaRequired, error, success, isLoading,
handleSubmit,
} = useResetPassword()
const shell = (inner: React.ReactNode) => (
<div style={{
+8 -33
View File
@@ -1,10 +1,7 @@
import React, { useState, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import React from 'react'
import { Settings, Palette, Map, Bell, Plug, CloudOff, User, Info } from 'lucide-react'
import { useTranslation } from '../i18n'
import { authApi } from '../api/client'
import { useAddonStore } from '../store/addonStore'
import Navbar from '../components/Layout/Navbar'
import PageShell from '../components/Layout/PageShell'
import PageSidebar, { type PageSidebarTab } from '../components/Layout/PageSidebar'
import DisplaySettingsTab from '../components/Settings/DisplaySettingsTab'
import MapSettingsTab from '../components/Settings/MapSettingsTab'
@@ -13,30 +10,12 @@ import IntegrationsTab from '../components/Settings/IntegrationsTab'
import AccountTab from '../components/Settings/AccountTab'
import AboutTab from '../components/Settings/AboutTab'
import OfflineTab from '../components/Settings/OfflineTab'
import { useSettings } from './settings/useSettings'
export default function SettingsPage(): React.ReactElement {
const { t } = useTranslation()
const [searchParams] = useSearchParams()
const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
const memoriesEnabled = addonEnabled('memories')
const mcpEnabled = addonEnabled('mcp')
const hasIntegrations = memoriesEnabled || mcpEnabled
const [appVersion, setAppVersion] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState('display')
useEffect(() => {
loadAddons()
authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {})
}, [])
// Auto-switch to account tab when MFA is required
useEffect(() => {
if (searchParams.get('mfa') === 'required') {
setActiveTab('account')
}
}, [searchParams])
// Page = wiring container: addon/version loading + active-tab state in the hook.
const { hasIntegrations, appVersion, activeTab, setActiveTab } = useSettings()
const tabs: PageSidebarTab[] = [
{ id: 'display', label: t('settings.tabs.display'), icon: Palette },
@@ -53,11 +32,8 @@ export default function SettingsPage(): React.ReactElement {
]
return (
<div className="min-h-screen" style={{ background: 'var(--bg-secondary)' }}>
<Navbar />
<div style={{ paddingTop: 'var(--nav-h)' }}>
<div className="w-full px-4 sm:px-6 lg:px-8 py-8">
<PageShell background="var(--bg-secondary)">
<div className="w-full px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ background: 'var(--bg-tertiary)' }}>
@@ -86,7 +62,6 @@ export default function SettingsPage(): React.ReactElement {
{activeTab === 'about' && appVersion && <AboutTab appVersion={appVersion} />}
</PageSidebar>
</div>
</div>
</div>
</PageShell>
)
}
+4 -14
View File
@@ -1,11 +1,10 @@
import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { useEffect } from 'react'
import { MapContainer, TileLayer, Marker, Tooltip, useMap } from 'react-leaflet'
import L from 'leaflet'
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
import { useSettingsStore } from '../store/settingsStore'
import { getLocaleForLanguage } from '../i18n'
import { shareApi } from '../api/client'
import { useSharedTrip } from './sharedTrip/useSharedTrip'
import { getCategoryIcon } from '../components/shared/categoryIcons'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
@@ -40,18 +39,9 @@ function FitBoundsToPlaces({ places }: { places: any[] }) {
}
export default function SharedTripPage() {
const { token } = useParams<{ token: string }>()
const { t, locale } = useTranslation()
const [data, setData] = useState<any>(null)
const [error, setError] = useState(false)
const [selectedDay, setSelectedDay] = useState<number | null>(null)
const [activeTab, setActiveTab] = useState('plan')
const [showLangPicker, setShowLangPicker] = useState(false)
useEffect(() => {
if (!token) return
shareApi.getSharedTrip(token).then(setData).catch(() => setError(true))
}, [token])
// Page = wiring container: share fetch + view state live in the hook.
const { data, error, selectedDay, setSelectedDay, activeTab, setActiveTab, showLangPicker, setShowLangPicker } = useSharedTrip()
if (error) return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: '#f3f4f6' }}>
+35 -578
View File
@@ -40,6 +40,7 @@ import { usePlaceSelection } from '../hooks/usePlaceSelection'
import { usePlannerHistory } from '../hooks/usePlannerHistory'
import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, TodoItem } from '../types'
import { ListTodo, Upload, Plus, Trash2, FolderPlus } from 'lucide-react'
import { useTripPlanner } from './tripPlanner/useTripPlanner'
function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; packingItems: PackingItem[]; todoItems: TodoItem[] }) {
const [subTab, setSubTab] = useState<'packing' | 'todo'>(() => {
@@ -167,584 +168,40 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
}
export default function TripPlannerPage(): React.ReactElement | null {
const { id: tripId } = useParams<{ id: string }>()
const navigate = useNavigate()
const toast = useToast()
const { t, language } = useTranslation()
const { settings } = useSettingsStore()
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
const trip = useTripStore(s => s.trip)
const days = useTripStore(s => s.days)
const places = useTripStore(s => s.places)
const assignments = useTripStore(s => s.assignments)
const packingItems = useTripStore(s => s.packingItems)
const todoItems = useTripStore(s => s.todoItems)
const categories = useTripStore(s => s.categories)
const reservations = useTripStore(s => s.reservations)
const budgetItems = useTripStore(s => s.budgetItems)
const files = useTripStore(s => s.files)
const selectedDayId = useTripStore(s => s.selectedDayId)
const isLoading = useTripStore(s => s.isLoading)
// Actions — stable references, don't cause re-renders
const tripActions = useRef(useTripStore.getState()).current
const can = useCanDo()
const canUploadFiles = can('file_upload', trip)
const { pushUndo, undo, canUndo, lastActionLabel } = usePlannerHistory()
const handleUndo = useCallback(async () => {
const label = lastActionLabel
await undo()
toast.info(t('undo.done', { action: label ?? '' }))
}, [undo, lastActionLabel, toast])
const [enabledAddons, setEnabledAddons] = useState<Record<string, boolean>>({ packing: true, budget: true, documents: true, collab: false })
const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, polls: true, whatsnext: true })
const [tripAccommodations, setTripAccommodations] = useState<Accommodation[]>([])
const [allowedFileTypes, setAllowedFileTypes] = useState<string | null>(null)
const [tripMembers, setTripMembers] = useState<TripMember[]>([])
const loadAccommodations = useCallback(() => {
if (tripId) {
accommodationRepo.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
tripActions.loadReservations(tripId)
}
}, [tripId])
useEffect(() => {
addonsApi.enabled().then(data => {
const map = {}
data.addons.forEach(a => { map[a.id] = true })
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab })
if (data.collabFeatures) setCollabFeatures(data.collabFeatures)
}).catch(() => {})
authApi.getAppConfig().then(config => {
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
}).catch(() => {})
}, [])
const TRANSPORT_TYPES = new Set(['flight', 'train', 'car', 'cruise', 'bus'])
const TRIP_TABS = [
{ id: 'plan', label: t('trip.tabs.plan'), icon: Map },
{ id: 'transports', label: t('trip.tabs.transports'), icon: Train },
{ id: 'buchungen', label: t('trip.tabs.reservations'), shortLabel: t('trip.tabs.reservationsShort'), icon: Ticket },
...(enabledAddons.packing ? [{ id: 'listen', label: t('trip.tabs.lists'), shortLabel: t('trip.tabs.listsShort'), icon: PackageCheck }] : []),
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget'), icon: Wallet }] : []),
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files'), icon: FolderOpen }] : []),
...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name'), icon: Users }] : []),
]
const [activeTab, setActiveTab] = useState<string>(() => {
const saved = sessionStorage.getItem(`trip-tab-${tripId}`)
return saved || 'plan'
})
useEffect(() => {
const validTabIds = TRIP_TABS.map(t => t.id)
if (!validTabIds.includes(activeTab)) {
setActiveTab('plan')
sessionStorage.setItem(`trip-tab-${tripId}`, 'plan')
}
}, [enabledAddons])
const handleTabChange = (tabId: string): void => {
setActiveTab(tabId)
sessionStorage.setItem(`trip-tab-${tripId}`, tabId)
if (tabId === 'finanzplan') tripActions.loadBudgetItems?.(tripId)
if (tabId === 'dateien' && (!files || files.length === 0)) tripActions.loadFiles?.(tripId)
}
const { leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight } = useResizablePanels()
const { selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment } = usePlaceSelection()
const [showDayDetail, setShowDayDetail] = useState<Day | null>(null)
const [dayDetailCollapsed, setDayDetailCollapsed] = useState(false)
const [showPlaceForm, setShowPlaceForm] = useState<boolean>(false)
const [editingPlace, setEditingPlace] = useState<Place | null>(null)
const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string } | null>(null)
const [editingAssignmentId, setEditingAssignmentId] = useState<number | null>(null)
const [searchParams, setSearchParams] = useSearchParams()
// The bottom-nav "+" opens the new-place form via ?create=place.
useEffect(() => {
if (searchParams.get('create') === 'place') {
setEditingPlace(null); setEditingAssignmentId(null); setShowPlaceForm(true)
setSearchParams(p => { p.delete('create'); return p }, { replace: true })
}
}, [searchParams])
const [showTripForm, setShowTripForm] = useState<boolean>(false)
const [showMembersModal, setShowMembersModal] = useState<boolean>(false)
const [showReservationModal, setShowReservationModal] = useState<boolean>(false)
const [editingReservation, setEditingReservation] = useState<Reservation | null>(null)
const [bookingForAssignmentId, setBookingForAssignmentId] = useState<number | null>(null)
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
const [transportModalDayId, setTransportModalDayId] = useState<number | null>(null)
// Manual route planning: off by default, toggled from the day-plan footer. Mode
// (driving/walking) is per-session and selects which travel time the connectors show.
const [routeShown, setRouteShown] = useState(false)
const [routeProfile, setRouteProfile] = useState<'driving' | 'walking'>('driving')
const [fitKey, setFitKey] = useState<number>(0)
const initialFitTripId = useRef<number | null>(null)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
const mobilePlanScrollTopRef = useRef<number>(0)
const mobilePlacesScrollTopRef = useRef<number>(0)
const [deletePlaceId, setDeletePlaceId] = useState<number | null>(null)
const [deletePlaceIds, setDeletePlaceIds] = useState<number[] | null>(null)
useEffect(() => {
if (!trip) return
if (initialFitTripId.current === trip.id) return
const hasGeoPlaces = places.some(p => p.lat != null && p.lng != null)
if (!hasGeoPlaces) return
initialFitTripId.current = trip.id
setFitKey(k => k + 1)
}, [trip, places])
const connectionsStorageKey = tripId ? `trek:visible-connections:${tripId}` : null
const [visibleConnections, setVisibleConnections] = useState<number[]>(() => {
if (typeof window === 'undefined' || !connectionsStorageKey) return []
try {
const stored = window.localStorage.getItem(connectionsStorageKey)
return stored ? JSON.parse(stored) as number[] : []
} catch { return [] }
})
useEffect(() => {
if (typeof window === 'undefined' || !connectionsStorageKey) return
window.localStorage.setItem(connectionsStorageKey, JSON.stringify(visibleConnections))
}, [connectionsStorageKey, visibleConnections])
const toggleConnection = useCallback((id: number) => {
setVisibleConnections(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
}, [])
const [mapTransportDetail, setMapTransportDetail] = useState<Reservation | null>(null)
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768)
useEffect(() => {
const mq = window.matchMedia('(max-width: 767px)')
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
// Start photo fetches during splash screen so images are ready when map mounts
useEffect(() => {
if (isLoading || !places || places.length === 0 || !placesPhotosEnabled) return
for (const p of places) {
if (p.image_url) continue
const cacheKey = p.google_place_id || p.osm_id || `${p.lat},${p.lng}`
if (!cacheKey || getCached(cacheKey)) continue
const photoId = p.google_place_id || p.osm_id
if (photoId || (p.lat && p.lng)) {
fetchPhoto(cacheKey, photoId || `coords:${p.lat}:${p.lng}`, p.lat, p.lng, p.name)
}
}
}, [isLoading, places])
// Load trip + files (needed for place inspector file section)
useEffect(() => {
if (tripId) {
tripActions.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
tripActions.loadFiles(tripId)
loadAccommodations()
if (!navigator.onLine) {
offlineDb.tripMembers.where('tripId').equals(Number(tripId)).toArray()
.then(rows => setTripMembers(rows))
.catch(() => {})
} else {
tripsApi.getMembers(tripId).then(d => {
const all = [d.owner, ...(d.members || [])].filter(Boolean)
setTripMembers(all)
}).catch(() => {})
}
}
}, [tripId])
useEffect(() => {
if (tripId) {
tripActions.loadReservations(tripId)
tripActions.loadBudgetItems?.(tripId)
}
}, [tripId])
useTripWebSocket(tripId)
const [mapCategoryFilter, setMapCategoryFilter] = useState<Set<string>>(new Set())
const [mapPlacesFilter, setMapPlacesFilter] = useState<string>('all')
const [expandedDayIds, setExpandedDayIds] = useState<Set<number> | null>(null)
const mapPlaces = useMemo(() => {
// Build set of place IDs assigned to collapsed days
const hiddenPlaceIds = new Set<number>()
if (expandedDayIds) {
for (const [dayId, dayAssignments] of Object.entries(assignments)) {
if (!expandedDayIds.has(Number(dayId))) {
for (const a of dayAssignments) {
if (a.place?.id) hiddenPlaceIds.add(a.place.id)
}
}
}
// Don't hide places that are also assigned to an expanded day
for (const [dayId, dayAssignments] of Object.entries(assignments)) {
if (expandedDayIds.has(Number(dayId))) {
for (const a of dayAssignments) {
hiddenPlaceIds.delete(a.place?.id)
}
}
}
}
// Build set of planned place IDs for unplanned filter
const plannedIds = mapPlacesFilter === 'unplanned'
? new Set(Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean)))
: null
return places.filter(p => {
if (!p.lat || !p.lng) return false
if (mapPlacesFilter === 'tracks' && !p.route_geometry) return false
if (mapCategoryFilter.size > 0) {
if (p.category_id == null) {
if (!mapCategoryFilter.has('uncategorized')) return false
} else if (!mapCategoryFilter.has(String(p.category_id))) return false
}
if (hiddenPlaceIds.has(p.id)) return false
if (plannedIds && plannedIds.has(p.id)) return false
return true
})
}, [places, mapCategoryFilter, mapPlacesFilter, assignments, expandedDayIds])
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile)
const handleSelectDay = useCallback((dayId, skipFit) => {
const changed = dayId !== selectedDayId
tripActions.setSelectedDay(dayId)
if (changed && !skipFit) setFitKey(k => k + 1)
setMobileSidebarOpen(null)
updateRouteForDay(dayId)
}, [updateRouteForDay, selectedDayId])
const handlePlaceClick = useCallback((placeId, assignmentId) => {
if (assignmentId) {
selectAssignment(assignmentId, placeId)
} else {
setSelectedPlaceId(placeId)
}
if (placeId) { setShowDayDetail(null); setLeftCollapsed(false); setRightCollapsed(false) }
}, [selectAssignment, setSelectedPlaceId])
const handleMarkerClick = useCallback((placeId) => {
if (placeId === undefined) {
setSelectedPlaceId(null)
return
}
// Find every assignment for this place (same place can sit on several
// days / be planned twice in one day). Cycle through them on repeated
// marker clicks so the sidebar highlight jumps to the next occurrence
// instead of leaving the user confused.
const allAssignments = Object.values(useTripStore.getState().assignments || {}).flat()
const matching = allAssignments.filter(a => a?.place?.id === placeId)
if (matching.length === 0) {
setSelectedPlaceId(prev => prev === placeId ? null : placeId)
} else if (matching.length === 1) {
const only = matching[0]
if (selectedAssignmentId === only.id) {
setSelectedPlaceId(null)
} else {
selectAssignment(only.id, placeId)
}
} else {
const currentIdx = matching.findIndex(a => a.id === selectedAssignmentId)
const nextIdx = currentIdx === -1 ? 0 : currentIdx + 1
if (nextIdx >= matching.length) {
// cycled past the last occurrence — clear selection so the next
// click starts fresh at occurrence 0.
setSelectedPlaceId(null)
} else {
selectAssignment(matching[nextIdx].id, placeId)
}
}
setLeftCollapsed(false); setRightCollapsed(false)
}, [selectAssignment, selectedAssignmentId, setSelectedPlaceId])
const handleMapClick = useCallback(() => {
setSelectedPlaceId(null)
}, [])
const handleMapContextMenu = useCallback(async (e) => {
if (!can('place_edit', trip)) return
e.originalEvent?.preventDefault()
const { lat, lng } = e.latlng
setPrefillCoords({ lat, lng })
setEditingPlace(null)
setEditingAssignmentId(null)
setShowPlaceForm(true)
try {
const { mapsApi } = await import('../api/client')
const data = await mapsApi.reverse(lat, lng, language)
if (data.name || data.address) {
setPrefillCoords(prev => prev ? { ...prev, name: data.name || '', address: data.address || '' } : prev)
}
} catch { /* best effort */ }
}, [language])
const handleSavePlace = useCallback(async (data) => {
const pendingFiles = data._pendingFiles
delete data._pendingFiles
if (editingPlace) {
// Always strip time fields from place update — time is per-assignment only
const { place_time, end_time, ...placeData } = data
await tripActions.updatePlace(tripId, editingPlace.id, placeData)
// If editing from assignment context, save time per-assignment
if (editingAssignmentId) {
await assignmentsApi.updateTime(tripId, editingAssignmentId, { place_time: place_time || null, end_time: end_time || null })
await tripActions.refreshDays(tripId)
}
// Upload pending files with place_id
if (pendingFiles?.length > 0) {
for (const file of pendingFiles) {
const fd = new FormData()
fd.append('file', file)
fd.append('place_id', editingPlace.id)
try { await tripActions.addFile(tripId, fd) } catch {}
}
}
toast.success(t('trip.toast.placeUpdated'))
} else {
const place = await tripActions.addPlace(tripId, data)
if (pendingFiles?.length > 0 && place?.id) {
for (const file of pendingFiles) {
const fd = new FormData()
fd.append('file', file)
fd.append('place_id', place.id)
try { await tripActions.addFile(tripId, fd) } catch {}
}
}
toast.success(t('trip.toast.placeAdded'))
if (place?.id) {
const capturedId = place.id
pushUndo(t('undo.addPlace'), async () => {
await tripActions.deletePlace(tripId, capturedId)
})
}
}
}, [editingPlace, editingAssignmentId, tripId, toast, pushUndo])
const handleDeletePlace = useCallback((placeId) => {
setDeletePlaceId(placeId)
}, [])
const confirmDeletePlace = useCallback(async () => {
if (!deletePlaceId) return
const state = useTripStore.getState()
const capturedPlace = state.places.find(p => p.id === deletePlaceId)
const capturedAssignments = Object.entries(state.assignments).flatMap(([dayId, as]) =>
as.filter(a => a.place?.id === deletePlaceId).map(a => ({ dayId: Number(dayId), orderIndex: a.order_index }))
)
try {
await tripActions.deletePlace(tripId, deletePlaceId)
if (selectedPlaceId === deletePlaceId) setSelectedPlaceId(null)
updateRouteForDay(selectedDayId)
toast.success(t('trip.toast.placeDeleted'))
if (capturedPlace) {
pushUndo(t('undo.deletePlace'), async () => {
const newPlace = await tripActions.addPlace(tripId, {
name: capturedPlace.name,
description: capturedPlace.description,
lat: capturedPlace.lat,
lng: capturedPlace.lng,
address: capturedPlace.address,
category_id: capturedPlace.category_id,
icon: capturedPlace.icon,
price: capturedPlace.price,
})
for (const { dayId, orderIndex } of capturedAssignments) {
await tripActions.assignPlaceToDay(tripId, dayId, newPlace.id, orderIndex)
}
})
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [deletePlaceId, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo])
const confirmDeletePlaces = useCallback(async (ids?: number[]) => {
const targetIds = ids ?? deletePlaceIds
if (!targetIds?.length) return
const state = useTripStore.getState()
const capturedPlaces = state.places.filter(p => targetIds.includes(p.id))
const capturedAssignments = Object.entries(state.assignments).flatMap(([dayId, as]) =>
as.filter(a => a.place?.id != null && targetIds.includes(a.place.id)).map(a => ({ dayId: Number(dayId), placeId: a.place!.id, orderIndex: a.order_index }))
)
try {
await tripActions.deletePlacesMany(tripId, targetIds)
if (selectedPlaceId != null && targetIds.includes(selectedPlaceId)) setSelectedPlaceId(null)
if (!ids) setDeletePlaceIds(null)
updateRouteForDay(selectedDayId)
toast.success(t('trip.toast.placesDeleted', { count: capturedPlaces.length }))
if (capturedPlaces.length > 0) {
pushUndo(t('undo.deletePlaces'), async () => {
for (const place of capturedPlaces) {
const newPlace = await tripActions.addPlace(tripId, {
name: place.name, description: place.description,
lat: place.lat, lng: place.lng, address: place.address,
category_id: place.category_id, icon: place.icon, price: place.price,
})
for (const a of capturedAssignments.filter(x => x.placeId === place.id)) {
await tripActions.assignPlaceToDay(tripId, a.dayId, newPlace.id, a.orderIndex)
}
}
})
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [deletePlaceIds, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo])
const handleAssignToDay = useCallback(async (placeId, dayId, position) => {
const target = dayId || selectedDayId
if (!target) { toast.error(t('trip.toast.selectDay')); return }
try {
const assignment = await tripActions.assignPlaceToDay(tripId, target, placeId, position)
toast.success(t('trip.toast.assignedToDay'))
updateRouteForDay(target)
if (assignment?.id) {
const capturedAssignmentId = assignment.id
const capturedTarget = target
pushUndo(t('undo.assignPlace'), async () => {
await tripActions.removeAssignment(tripId, capturedTarget, capturedAssignmentId)
})
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [selectedDayId, tripId, toast, updateRouteForDay, pushUndo])
const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => {
const state = useTripStore.getState()
const capturedAssignment = (state.assignments[String(dayId)] || []).find(a => a.id === assignmentId)
const capturedPlaceId = capturedAssignment?.place?.id
const capturedOrderIndex = capturedAssignment?.order_index ?? 0
try {
await tripActions.removeAssignment(tripId, dayId, assignmentId)
updateRouteForDay(dayId)
if (capturedPlaceId != null) {
const capturedDayId = dayId
const capturedPos = capturedOrderIndex
pushUndo(t('undo.removeAssignment'), async () => {
await tripActions.assignPlaceToDay(tripId, capturedDayId, capturedPlaceId, capturedPos)
})
}
}
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [tripId, toast, updateRouteForDay, pushUndo])
const handleReorder = useCallback((dayId, orderedIds) => {
const prevIds = (useTripStore.getState().assignments[String(dayId)] || [])
.slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id)
try {
tripActions.reorderAssignments(tripId, dayId, orderedIds)
.then(() => {
const capturedDayId = dayId
const capturedPrevIds = prevIds
pushUndo(t('undo.reorder'), async () => {
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds)
})
})
.catch(err => toast.error(err instanceof Error ? err.message : t('trip.toast.reorderError')))
updateRouteForDay(dayId)
}
catch { toast.error(t('trip.toast.reorderError')) }
}, [tripId, toast, pushUndo, updateRouteForDay])
const handleUpdateDayTitle = useCallback(async (dayId, title) => {
try { await tripActions.updateDayTitle(tripId, dayId, title) }
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [tripId, toast])
const handleSaveReservation = async (data) => {
try {
if (editingReservation) {
const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null })
toast.success(t('trip.toast.reservationUpdated'))
setShowReservationModal(false)
setEditingReservation(null)
if (data.type === 'hotel') {
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
}
return r
} else {
const r = await tripActions.addReservation(tripId, { ...data, day_id: selectedDayId || null })
toast.success(t('trip.toast.reservationAdded'))
setShowReservationModal(false)
// Refresh accommodations if hotel was created
if (data.type === 'hotel') {
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
}
return r
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}
const handleSaveTransport = async (data) => {
try {
if (editingTransport) {
const r = await tripActions.updateReservation(tripId, editingTransport.id, data)
toast.success(t('trip.toast.reservationUpdated'))
setShowTransportModal(false)
setEditingTransport(null)
setTransportModalDayId(null)
return r
} else {
const r = await tripActions.addReservation(tripId, data)
toast.success(t('trip.toast.reservationAdded'))
setShowTransportModal(false)
setEditingTransport(null)
setTransportModalDayId(null)
return r
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}
const handleDeleteReservation = async (id) => {
try {
await tripActions.deleteReservation(tripId, id)
toast.success(t('trip.toast.deleted'))
// Refresh accommodations in case a hotel booking was deleted
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
}
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
// Build placeId → order-number map from the selected day's assignments
const dayOrderMap = useMemo(() => {
if (!selectedDayId) return {}
const da = assignments[String(selectedDayId)] || []
const sorted = [...da].sort((a, b) => a.order_index - b.order_index)
const map = {}
sorted.forEach((a, i) => {
if (!a.place?.id) return
if (!map[a.place.id]) map[a.place.id] = []
map[a.place.id].push(i + 1)
})
return map
}, [selectedDayId, assignments])
// Places assigned to selected day (with coords) — used for map fitting
const dayPlaces = useMemo(() => {
if (!selectedDayId) return []
const da = assignments[String(selectedDayId)] || []
return da.map(a => a.place).filter(p => p?.lat && p?.lng)
}, [selectedDayId, assignments])
const mapTileUrl = settings.map_tile_url || 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
const defaultCenter = [settings.default_lat || 48.8566, settings.default_lng || 2.3522]
const defaultZoom = settings.default_zoom || 10
const fontStyle = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif" }
// Splash screen — show for initial load + a brief moment for photos to start loading
const [splashDone, setSplashDone] = useState(false)
useEffect(() => {
if (!isLoading && trip) {
const timer = setTimeout(() => setSplashDone(true), 1500)
return () => clearTimeout(timer)
}
}, [isLoading, trip])
// Page = wiring container: the entire planner state machine (store, tabs,
// selection, CRUD handlers with undo, map filters, splash) lives in the hook.
const {
tripId, navigate, toast, t, language, settings, placesPhotosEnabled,
trip, days, places, assignments, packingItems, todoItems, categories, reservations, budgetItems, files,
selectedDayId, isLoading, tripActions, can, canUploadFiles,
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
allowedFileTypes, tripMembers, setTripMembers, loadAccommodations,
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight,
selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment,
showDayDetail, setShowDayDetail, dayDetailCollapsed, setDayDetailCollapsed,
showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace,
prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId,
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
bookingForAssignmentId, setBookingForAssignmentId,
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
transportModalDayId, setTransportModalDayId,
routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey,
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds,
visibleConnections, setVisibleConnections, toggleConnection, mapTransportDetail, setMapTransportDetail,
isMobile, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter,
expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu,
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
handleAssignToDay, handleRemoveAssignment, handleReorder, handleUpdateDayTitle,
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
selectedPlace, dayOrderMap, dayPlaces,
mapTileUrl, defaultCenter, defaultZoom, fontStyle, splashDone,
} = useTripPlanner()
if (isLoading || !splashDone) {
return (
+16 -53
View File
@@ -1,64 +1,31 @@
import React, { useEffect, useState, useCallback } from 'react'
import React from 'react'
import ReactDOM from 'react-dom'
import { useTranslation } from '../i18n'
import { useVacayStore } from '../store/vacayStore'
import { addListener, removeListener } from '../api/websocket'
import Navbar from '../components/Layout/Navbar'
import PageShell from '../components/Layout/PageShell'
import VacayCalendar from '../components/Vacay/VacayCalendar'
import VacayPersons from '../components/Vacay/VacayPersons'
import VacayStats from '../components/Vacay/VacayStats'
import VacaySettings from '../components/Vacay/VacaySettings'
import { Plus, Minus, ChevronLeft, ChevronRight, Settings, CalendarDays, AlertTriangle, Users, Eye, Pencil, Trash2, Unlink, ShieldCheck, SlidersHorizontal } from 'lucide-react'
import Modal from '../components/shared/Modal'
import { useVacay } from './vacay/useVacay'
export default function VacayPage(): React.ReactElement {
const { t } = useTranslation()
const { years, selectedYear, setSelectedYear, addYear, removeYear, loadAll, loadPlan, loadEntries, loadStats, loadHolidays, loading, incomingInvites, acceptInvite, declineInvite, plan } = useVacayStore()
const [showSettings, setShowSettings] = useState<boolean>(false)
const [deleteYear, setDeleteYear] = useState<number | null>(null)
const [showMobileSidebar, setShowMobileSidebar] = useState<boolean>(false)
useEffect(() => { loadAll() }, [])
// Live sync via WebSocket
const handleWsMessage = useCallback((msg: { type: string }) => {
if (msg.type === 'vacay:update' || msg.type === 'vacay:settings') {
loadPlan()
loadEntries(selectedYear)
loadStats(selectedYear)
if (msg.type === 'vacay:settings') loadAll()
}
if (msg.type === 'vacay:invite' || msg.type === 'vacay:accepted' || msg.type === 'vacay:declined' || msg.type === 'vacay:cancelled' || msg.type === 'vacay:dissolved') {
loadAll()
}
}, [selectedYear])
useEffect(() => {
addListener(handleWsMessage)
return () => removeListener(handleWsMessage)
}, [handleWsMessage])
useEffect(() => {
if (selectedYear) { loadEntries(selectedYear); loadStats(selectedYear); loadHolidays(selectedYear) }
}, [selectedYear])
const handleAddNextYear = () => {
const nextYear = years.length > 0 ? Math.max(...years) + 1 : new Date().getFullYear()
addYear(nextYear)
}
const handleAddPrevYear = () => {
const prevYear = years.length > 0 ? Math.min(...years) - 1 : new Date().getFullYear()
addYear(prevYear)
}
// Page = wiring container: vacay store, live sync + UI state live in the hook.
const {
years, selectedYear, setSelectedYear, removeYear, loading,
incomingInvites, acceptInvite, declineInvite, plan,
showSettings, setShowSettings, deleteYear, setDeleteYear,
showMobileSidebar, setShowMobileSidebar,
handleAddNextYear, handleAddPrevYear,
} = useVacay()
if (loading) {
return (
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
<Navbar />
<div className="flex items-center justify-center" style={{ paddingTop: 'var(--nav-h)', minHeight: 'calc(100vh - var(--nav-h))' }}>
<div className="w-8 h-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
</div>
</div>
<PageShell background="var(--bg-primary)" contentClassName="flex items-center justify-center" contentStyle={{ minHeight: 'calc(100vh - var(--nav-h))' }}>
<div className="w-8 h-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
</PageShell>
)
}
@@ -133,10 +100,7 @@ export default function VacayPage(): React.ReactElement {
)
return (
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
<Navbar />
<div style={{ paddingTop: 'var(--nav-h)' }}>
<PageShell background="var(--bg-primary)">
<div className="max-w-[1800px] mx-auto px-3 sm:px-4 py-4 sm:py-6">
{/* Mobile + tablet header (filter toggle lives here) */}
<div className="lg:hidden flex items-center justify-between mb-4">
@@ -213,7 +177,6 @@ export default function VacayPage(): React.ReactElement {
</div>
</div>
</div>
</div>
{/* Mobile Sidebar Drawer */}
{showMobileSidebar && ReactDOM.createPortal(
@@ -307,7 +270,7 @@ export default function VacayPage(): React.ReactElement {
to { transform: translateX(0); }
}
`}</style>
</div>
</PageShell>
)
}
+38
View File
@@ -0,0 +1,38 @@
/** Shared types for the admin page + its data hook. No React, no side effects. */
export interface AdminUser {
id: number
username: string
email: string
role: 'admin' | 'user'
created_at: string
last_login?: string | null
online?: boolean
oidc_issuer?: string | null
avatar_url?: string | null
}
export interface AdminStats {
totalUsers: number
totalTrips: number
totalPlaces: number
totalFiles: number
}
export interface OidcConfig {
issuer: string
client_id: string
client_secret: string
client_secret_set: boolean
display_name: string
discovery_url: string
}
export interface UpdateInfo {
update_available: boolean
latest: string
current: string
release_url?: string
is_docker?: boolean
is_prerelease?: boolean
}
+357
View File
@@ -0,0 +1,357 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import apiClient, { adminApi, authApi } from '../../api/client'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { useTranslation } from '../../i18n'
import { getApiErrorMessage } from '../../types'
import { useToast } from '../../components/shared/Toast'
import type { AdminUser, AdminStats, OidcConfig, UpdateInfo } from './adminModel'
/**
* Admin page logic owns every admin data slice (users, stats, invites, auth
* toggles, OIDC, feature flags, API keys, SMTP, version/update) plus the CRUD
* and toggle handlers. AdminPage stays a wiring container that builds the
* (t-dependent) tab list and renders the tab panels around this state.
* Behaviour is identical to the previous in-component logic.
*/
export function useAdmin() {
const { demoMode, serverTimezone } = useAuthStore()
const { t } = useTranslation()
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
const mcpEnabled = useAddonStore(s => s.isEnabled('mcp'))
const devMode = useAuthStore(s => s.devMode)
const [activeTab, setActiveTab] = useState<string>('users')
const [users, setUsers] = useState<AdminUser[]>([])
const [stats, setStats] = useState<AdminStats | null>(null)
const [isLoading, setIsLoading] = useState<boolean>(true)
const [editingUser, setEditingUser] = useState<AdminUser | null>(null)
const [editForm, setEditForm] = useState<{ username: string; email: string; role: string; password: string }>({ username: '', email: '', role: 'user', password: '' })
const [showCreateUser, setShowCreateUser] = useState<boolean>(false)
const [createForm, setCreateForm] = useState<{ username: string; email: string; password: string; role: string }>({ username: '', email: '', password: '', role: 'user' })
// Bag tracking
const [bagTrackingEnabled, setBagTrackingEnabled] = useState<boolean>(false)
useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, [])
// Places photos
const [placesPhotosEnabled, setPlacesPhotosEnabledState] = useState<boolean>(true)
useEffect(() => { adminApi.getPlacesPhotos().then(d => setPlacesPhotosEnabledState(d.enabled)).catch(() => {}) }, [])
// Places autocomplete
const [placesAutocompleteEnabled, setPlacesAutocompleteEnabledState] = useState<boolean>(true)
useEffect(() => { adminApi.getPlacesAutocomplete().then(d => setPlacesAutocompleteEnabledState(d.enabled)).catch(() => {}) }, [])
// Places details
const [placesDetailsEnabled, setPlacesDetailsEnabledState] = useState<boolean>(true)
useEffect(() => { adminApi.getPlacesDetails().then(d => setPlacesDetailsEnabledState(d.enabled)).catch(() => {}) }, [])
// Collab features
const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, polls: true, whatsnext: true })
useEffect(() => { adminApi.getCollabFeatures().then(d => setCollabFeatures(d)).catch(() => {}) }, [])
// OIDC config
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', discovery_url: '' })
const [savingOidc, setSavingOidc] = useState<boolean>(false)
// Auth toggles
const [passwordLogin, setPasswordLogin] = useState<boolean>(true)
const [passwordRegistration, setPasswordRegistration] = useState<boolean>(true)
const [oidcLogin, setOidcLogin] = useState<boolean>(true)
const [oidcRegistration, setOidcRegistration] = useState<boolean>(true)
const [envOverrideOidcOnly, setEnvOverrideOidcOnly] = useState<boolean>(false)
const [oidcConfigured, setOidcConfigured] = useState<boolean>(false)
const [requireMfa, setRequireMfa] = useState<boolean>(false)
// Invite links
const [invites, setInvites] = useState<any[]>([])
const [showCreateInvite, setShowCreateInvite] = useState<boolean>(false)
const [inviteForm, setInviteForm] = useState<{ max_uses: number; expires_in_days: number | '' }>({ max_uses: 1, expires_in_days: 7 })
// File types
const [allowedFileTypes, setAllowedFileTypes] = useState<string>('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv')
const [savingFileTypes, setSavingFileTypes] = useState<boolean>(false)
// SMTP settings
const [smtpValues, setSmtpValues] = useState<Record<string, string>>({})
const [smtpLoaded, setSmtpLoaded] = useState(false)
useEffect(() => {
apiClient.get('/auth/app-settings').then(r => {
setSmtpValues(r.data || {})
setSmtpLoaded(true)
}).catch(() => setSmtpLoaded(true))
}, [])
// API Keys
const [mapsKey, setMapsKey] = useState<string>('')
const [weatherKey, setWeatherKey] = useState<string>('')
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({})
const [savingKeys, setSavingKeys] = useState<boolean>(false)
const [validating, setValidating] = useState<Record<string, boolean>>({})
const [validation, setValidation] = useState<Record<string, boolean | undefined>>({})
// Version check & update
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false)
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, logout } = useAuthStore()
const navigate = useNavigate()
const toast = useToast()
const [showRotateJwtModal, setShowRotateJwtModal] = useState<boolean>(false)
const [rotatingJwt, setRotatingJwt] = useState<boolean>(false)
useEffect(() => {
loadData()
loadAppConfig()
loadApiKeys()
adminApi.getOidc().then(setOidcConfig).catch(() => {})
adminApi.checkVersion().then(data => {
if (data.update_available) setUpdateInfo(data)
}).catch(() => {})
}, [])
const loadData = async () => {
setIsLoading(true)
try {
const [usersData, statsData, invitesData] = await Promise.all([
adminApi.users(),
adminApi.stats(),
adminApi.listInvites().catch(() => ({ invites: [] })),
])
setUsers(usersData.users)
setStats(statsData)
setInvites(invitesData.invites || [])
} catch (err: unknown) {
toast.error(t('admin.toast.loadError'))
} finally {
setIsLoading(false)
}
}
const loadAppConfig = async () => {
try {
const config = await authApi.getAppConfig()
setPasswordLogin(config.password_login ?? true)
setPasswordRegistration(config.password_registration ?? config.allow_registration ?? true)
setOidcLogin(config.oidc_login ?? true)
setOidcRegistration(config.oidc_registration ?? config.allow_registration ?? true)
setEnvOverrideOidcOnly(config.env_override_oidc_only ?? false)
setOidcConfigured(config.oidc_configured ?? false)
if (config.require_mfa !== undefined) setRequireMfa(!!config.require_mfa)
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
} catch (err: unknown) {
// ignore
}
}
const loadApiKeys = async () => {
try {
const data = await authApi.getSettings()
setMapsKey(data.settings?.maps_api_key || '')
setWeatherKey(data.settings?.openweather_api_key || '')
} catch (err: unknown) {
// ignore
}
}
const handleToggleAuthSetting = async (key: string, value: boolean, setter: (v: boolean) => void) => {
setter(value)
try {
await authApi.updateAppSettings({ [key]: value })
} catch (err: unknown) {
setter(!value)
toast.error(getApiErrorMessage(err, t('common.error')))
}
}
const handleToggleRequireMfa = async (value: boolean) => {
setRequireMfa(value)
try {
await authApi.updateAppSettings({ require_mfa: value })
setAppRequireMfa(value)
toast.success(t('common.saved'))
} catch (err: unknown) {
setRequireMfa(!value)
toast.error(getApiErrorMessage(err, t('common.error')))
}
}
const toggleKey = (key) => {
setShowKeys(prev => ({ ...prev, [key]: !prev[key] }))
}
const handleSaveApiKeys = async () => {
setSavingKeys(true)
try {
await updateApiKeys({
maps_api_key: mapsKey,
openweather_api_key: weatherKey,
})
toast.success(t('admin.keySaved'))
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : 'Unknown error')
} finally {
setSavingKeys(false)
}
}
const handleValidateKeys = async () => {
setValidating({ maps: true, weather: true })
try {
// Save first so validation uses the current values
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey })
const result = await authApi.validateKeys()
setValidation(result)
} catch (err: unknown) {
toast.error(t('common.error'))
} finally {
setValidating({})
}
}
const handleValidateKey = async (keyType) => {
setValidating(prev => ({ ...prev, [keyType]: true }))
try {
// Save first so validation uses the current values
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey })
const result = await authApi.validateKeys()
setValidation(prev => ({ ...prev, [keyType]: result[keyType] }))
} catch (err: unknown) {
toast.error(t('common.error'))
} finally {
setValidating(prev => ({ ...prev, [keyType]: false }))
}
}
const handleCreateUser = async () => {
if (!createForm.username.trim() || !createForm.email.trim() || !createForm.password.trim()) {
toast.error(t('admin.toast.fieldsRequired'))
return
}
if (createForm.password.trim().length < 8) {
toast.error(t('settings.passwordTooShort'))
return
}
try {
const data = await adminApi.createUser(createForm)
setUsers(prev => [data.user, ...prev])
setShowCreateUser(false)
setCreateForm({ username: '', email: '', password: '', role: 'user' })
toast.success(t('admin.toast.userCreated'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('admin.toast.createError')))
}
}
const handleCreateInvite = async () => {
try {
const data = await adminApi.createInvite({
max_uses: inviteForm.max_uses,
expires_in_days: inviteForm.expires_in_days || undefined,
})
setInvites(prev => [data.invite, ...prev])
setShowCreateInvite(false)
setInviteForm({ max_uses: 1, expires_in_days: 7 })
// Copy link to clipboard
const link = `${window.location.origin}/register?invite=${data.invite.token}`
navigator.clipboard.writeText(link).then(() => toast.success(t('admin.invite.copied')))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('admin.invite.createError')))
}
}
const handleDeleteInvite = async (id: number) => {
try {
await adminApi.deleteInvite(id)
setInvites(prev => prev.filter(i => i.id !== id))
toast.success(t('admin.invite.deleted'))
} catch {
toast.error(t('admin.invite.deleteError'))
}
}
const copyInviteLink = (token: string) => {
const link = `${window.location.origin}/register?invite=${token}`
navigator.clipboard.writeText(link).then(() => toast.success(t('admin.invite.copied')))
}
const handleEditUser = (user) => {
setEditingUser(user)
setEditForm({ username: user.username, email: user.email, role: user.role, password: '' })
}
const handleSaveUser = async () => {
try {
const payload: { username?: string; email?: string; role: string; password?: string } = {
username: editForm.username.trim() || undefined,
email: editForm.email.trim() || undefined,
role: editForm.role,
}
if (editForm.password.trim()) {
if (editForm.password.trim().length < 8) {
toast.error(t('settings.passwordTooShort'))
return
}
payload.password = editForm.password.trim()
}
const data = await adminApi.updateUser(editingUser.id, payload)
setUsers(prev => prev.map(u => u.id === editingUser.id ? data.user : u))
setEditingUser(null)
toast.success(t('admin.toast.userUpdated'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('admin.toast.updateError')))
}
}
const handleDeleteUser = async (user) => {
if (user.id === currentUser?.id) {
toast.error(t('admin.toast.cannotDeleteSelf'))
return
}
if (!confirm(t('admin.deleteUser', { name: user.username }))) return
try {
await adminApi.deleteUser(user.id)
setUsers(prev => prev.filter(u => u.id !== user.id))
toast.success(t('admin.toast.userDeleted'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('admin.toast.deleteError')))
}
}
return {
// store-derived
demoMode, serverTimezone, hour12, mcpEnabled, devMode, currentUser,
updateApiKeys, setAppRequireMfa, setTripRemindersEnabled,
setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, logout,
navigate, toast,
// state + setters
activeTab, setActiveTab, users, setUsers, stats, isLoading,
editingUser, setEditingUser, editForm, setEditForm,
showCreateUser, setShowCreateUser, createForm, setCreateForm,
bagTrackingEnabled, setBagTrackingEnabled,
placesPhotosEnabled, setPlacesPhotosEnabledState,
placesAutocompleteEnabled, setPlacesAutocompleteEnabledState,
placesDetailsEnabled, setPlacesDetailsEnabledState,
collabFeatures, setCollabFeatures,
oidcConfig, setOidcConfig, savingOidc, setSavingOidc,
passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration,
oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration,
envOverrideOidcOnly, setEnvOverrideOidcOnly, oidcConfigured, setOidcConfigured,
requireMfa, setRequireMfa,
invites, setInvites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
smtpValues, setSmtpValues, smtpLoaded,
mapsKey, setMapsKey, weatherKey, setWeatherKey,
showKeys, setShowKeys, savingKeys, validating, validation,
updateInfo, setUpdateInfo, showUpdateModal, setShowUpdateModal,
showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt,
// handlers
loadData, loadAppConfig, loadApiKeys, handleToggleAuthSetting, handleToggleRequireMfa,
toggleKey, handleSaveApiKeys, handleValidateKeys, handleValidateKey,
handleCreateUser, handleCreateInvite, handleDeleteInvite, copyInviteLink,
handleEditUser, handleSaveUser, handleDeleteUser,
}
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Shared types + pure helpers for the Atlas page. No React, no side effects.
* A2_TO_A3 is deliberately a mutable module-level object: the geoData load
* effect in useAtlas augments it at runtime, and both the hook (visited-country
* colouring) and the page's SidebarContent read it they must share one
* reference, so it lives here rather than inside either consumer.
*/
export interface AtlasCountry {
code: string
tripCount: number
placeCount: number
firstVisit?: string | null
lastVisit?: string | null
}
export interface AtlasStats {
totalTrips: number
totalPlaces: number
totalCountries: number
totalDays: number
totalCities?: number
}
export interface AtlasData {
countries: AtlasCountry[]
stats: AtlasStats
mostVisited?: AtlasCountry | null
continents?: Record<string, number>
lastTrip?: { id: number; title: string; countryCode?: string } | null
nextTrip?: { id: number; title: string; countryCode?: string } | null
streak?: number
firstYear?: number
tripsThisYear?: number
}
export interface CountryDetail {
places: import('../../types').AtlasPlace[]
trips: { id: number; title: string }[]
manually_marked?: boolean
}
export interface BucketItem {
id: number
name: string
lat: number | null
lng: number | null
country_code: string | null
notes: string | null
target_date: string | null
}
// Convert country code to flag emoji
export function countryCodeToFlag(code: string): string {
if (!code || code.length !== 2) return ''
return String.fromCodePoint(...[...code.toUpperCase()].map(c => 0x1F1E6 + c.charCodeAt(0) - 65))
}
// ISO-3166-1 alpha-2 → alpha-3 mapping. Two sources feed this table:
// 1. Hardcoded entries below — REQUIRED for any country whose Natural Earth GeoJSON record
// has ISO_A2='-99' (e.g. France=FRA, Norway=NOR). The runtime augmentation loop
// (see geoData useEffect below) skips '-99' features, so those countries MUST be
// listed here or the atlas_country_options A3-fallback will silently fail.
// 2. Runtime augmentation — the geoData load effect adds entries for every feature
// that has a valid ISO_A2, covering territories not present below.
export const A2_TO_A3: Record<string, string> = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AG":"ATG","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BS":"BHS","BH":"BHR","BD":"BGD","BB":"BRB","BY":"BLR","BE":"BEL","BZ":"BLZ","BJ":"BEN","BT":"BTN","BO":"BOL","BA":"BIH","BW":"BWA","BR":"BRA","BN":"BRN","BG":"BGR","BF":"BFA","BI":"BDI","CV":"CPV","KH":"KHM","CM":"CMR","CA":"CAN","CF":"CAF","TD":"TCD","CL":"CHL","CN":"CHN","CO":"COL","KM":"COM","CG":"COG","CD":"COD","CR":"CRI","CI":"CIV","HR":"HRV","CU":"CUB","CY":"CYP","CZ":"CZE","DK":"DNK","DJ":"DJI","DM":"DMA","DO":"DOM","EC":"ECU","EG":"EGY","SV":"SLV","GQ":"GNQ","ER":"ERI","EE":"EST","SZ":"SWZ","ET":"ETH","FJ":"FJI","FI":"FIN","FR":"FRA","GA":"GAB","GM":"GMB","GE":"GEO","DE":"DEU","GH":"GHA","GR":"GRC","GD":"GRD","GT":"GTM","GN":"GIN","GW":"GNB","GY":"GUY","HT":"HTI","HN":"HND","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JM":"JAM","JP":"JPN","JO":"JOR","KZ":"KAZ","KE":"KEN","KI":"KIR","KP":"PRK","KR":"KOR","KW":"KWT","KG":"KGZ","LA":"LAO","LV":"LVA","LB":"LBN","LS":"LSO","LR":"LBR","LY":"LBY","LI":"LIE","LT":"LTU","LU":"LUX","MG":"MDG","MW":"MWI","MY":"MYS","MV":"MDV","ML":"MLI","MT":"MLT","MR":"MRT","MU":"MUS","MX":"MEX","MD":"MDA","MN":"MNG","ME":"MNE","MA":"MAR","MZ":"MOZ","MM":"MMR","NA":"NAM","NP":"NPL","NL":"NLD","NZ":"NZL","NI":"NIC","NE":"NER","NG":"NGA","MK":"MKD","NO":"NOR","OM":"OMN","PK":"PAK","PA":"PAN","PG":"PNG","PY":"PRY","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","QA":"QAT","RO":"ROU","RU":"RUS","RW":"RWA","SA":"SAU","SN":"SEN","RS":"SRB","SL":"SLE","SG":"SGP","SK":"SVK","SI":"SVN","SB":"SLB","SO":"SOM","ZA":"ZAF","SS":"SSD","ES":"ESP","LK":"LKA","SD":"SDN","SR":"SUR","SE":"SWE","CH":"CHE","SY":"SYR","TW":"TWN","TJ":"TJK","TZ":"TZA","TH":"THA","TL":"TLS","TG":"TGO","TT":"TTO","TN":"TUN","TR":"TUR","TM":"TKM","UG":"UGA","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","UY":"URY","UZ":"UZB","VU":"VUT","VE":"VEN","VN":"VNM","YE":"YEM","ZM":"ZMB","ZW":"ZWE"}
+691
View File
@@ -0,0 +1,691 @@
import React, { useEffect, useMemo, useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import apiClient, { mapsApi } from '../../api/client'
import L from 'leaflet'
import type { GeoJsonFeatureCollection } from '../../types'
import { A2_TO_A3, type AtlasData, type CountryDetail, type BucketItem } from './atlasModel'
function useCountryNames(language: string): (code: string) => string {
const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code)
useEffect(() => {
try {
const dn = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' })
setResolver(() => (code: string) => { try { return dn.of(code) || code } catch { return code } })
} catch { /* */ }
}, [language])
return resolver
}
/**
* Atlas page logic the whole interactive globe lives here: atlas/bucket-list
* loading, the Leaflet map lifecycle (country + sub-national region layers,
* bucket markers, viewport-driven region fetching), country/region mark/unmark
* flows and the country search. AtlasPage stays a wiring container that renders
* the returned state via its presentational SidebarContent/MobileStats helpers.
* Behaviour is identical to the previous in-component logic.
*/
export function useAtlas() {
const { t, language } = useTranslation()
const { settings } = useSettingsStore()
const navigate = useNavigate()
const resolveName = useCountryNames(language)
const dm = settings.dark_mode
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const mapRef = useRef<HTMLDivElement>(null)
const mapInstance = useRef<L.Map | null>(null)
const geoLayerRef = useRef<L.GeoJSON | null>(null)
const glareRef = useRef<HTMLDivElement>(null)
const borderGlareRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const country_layer_by_a2_ref = useRef<Record<string, any>>({})
const handlePanelMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return
const rect = panelRef.current.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
// Subtle inner glow
glareRef.current.style.background = `radial-gradient(circle 300px at ${x}px ${y}px, ${dark ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.25)'} 0%, transparent 70%)`
glareRef.current.style.opacity = '1'
// Border glow that follows cursor
borderGlareRef.current.style.opacity = '1'
borderGlareRef.current.style.maskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
borderGlareRef.current.style.webkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
}
const handlePanelMouseLeave = () => {
if (glareRef.current) glareRef.current.style.opacity = '0'
if (borderGlareRef.current) borderGlareRef.current.style.opacity = '0'
}
const [data, setData] = useState<AtlasData | null>(null)
const [loading, setLoading] = useState<boolean>(true)
const [sidebarOpen, setSidebarOpen] = useState<boolean>(true)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<boolean>(false)
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
const [countryDetail, setCountryDetail] = useState<CountryDetail | null>(null)
const [geoData, setGeoData] = useState<GeoJsonFeatureCollection | null>(null)
const [visitedRegions, setVisitedRegions] = useState<Record<string, { code: string; name: string; placeCount: number; manuallyMarked?: boolean }[]>>({})
const regionLayerRef = useRef<L.GeoJSON | null>(null)
const regionGeoCache = useRef<Record<string, GeoJsonFeatureCollection>>({})
const [showRegions, setShowRegions] = useState(false)
const [regionGeoLoaded, setRegionGeoLoaded] = useState(0)
const regionTooltipRef = useRef<HTMLDivElement>(null)
const loadCountryDetailRef = useRef<(code: string) => void>(() => {})
const handleMarkCountryRef = useRef<(code: string, name: string) => void>(() => {})
const setConfirmActionRef = useRef<typeof setConfirmAction>(() => {})
const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket' | 'choose-region' | 'unmark-region'; code: string; name: string; regionCode?: string; countryName?: string } | null>(null)
const [bucketMonth, setBucketMonth] = useState(0)
const [bucketYear, setBucketYear] = useState(0)
// Bucket list
const [bucketList, setBucketList] = useState<BucketItem[]>([])
const [showBucketAdd, setShowBucketAdd] = useState(false)
const [bucketForm, setBucketForm] = useState({ name: '', notes: '', lat: '', lng: '', target_date: '' })
const [bucketSearch, setBucketSearch] = useState('')
const [bucketSearchResults, setBucketSearchResults] = useState<any[]>([])
const [bucketSearching, setBucketSearching] = useState(false)
const [bucketPoiMonth, setBucketPoiMonth] = useState(0)
const [bucketPoiYear, setBucketPoiYear] = useState(0)
const [bucketTab, setBucketTab] = useState<'stats' | 'bucket'>('stats')
const bucketMarkersRef = useRef<any>(null)
const [atlas_country_search, set_atlas_country_search] = useState('')
const [atlas_country_results, set_atlas_country_results] = useState<{ code: string; label: string }[]>([])
const [atlas_country_open, set_atlas_country_open] = useState(false)
const atlas_country_options = useMemo(() => {
if (!geoData) return []
// Precompute A3 → A2 reverse lookup once per geoData change instead of
// scanning A2_TO_A3 for every feature that needs the fallback.
const a3ToA2 = new Map<string, string>()
for (const [a2Key, a3Val] of Object.entries(A2_TO_A3)) a3ToA2.set(a3Val, a2Key)
const opts: { code: string; label: string }[] = []
const seen = new Set<string>()
for (const f of (geoData as any).features || []) {
const rawA2 = f?.properties?.ISO_A2
let resolvedA2: string | null = (typeof rawA2 === 'string' && rawA2.length === 2 && rawA2 !== '-99') ? rawA2 : null
if (!resolvedA2) {
const a3 = f?.properties?.ADM0_A3 || f?.properties?.ISO_A3 || f?.properties?.['ISO3166-1-Alpha-3'] || null
if (a3 && a3 !== '-99') resolvedA2 = a3ToA2.get(a3) ?? null
}
if (!resolvedA2 || seen.has(resolvedA2)) continue
seen.add(resolvedA2)
const label = String(resolveName(resolvedA2) || f?.properties?.NAME || f?.properties?.ADMIN || resolvedA2)
opts.push({ code: resolvedA2, label })
}
opts.sort((a, b) => a.label.localeCompare(b.label))
return opts
}, [geoData, resolveName])
// Load atlas data + bucket list
useEffect(() => {
Promise.all([
apiClient.get('/addons/atlas/stats'),
apiClient.get('/addons/atlas/bucket-list'),
]).then(([statsRes, bucketRes]) => {
setData(statsRes.data)
setBucketList(bucketRes.data.items || [])
setLoading(false)
}).catch(() => setLoading(false))
}, [])
// Load GeoJSON world data (direct GeoJSON, no conversion needed)
useEffect(() => {
fetch('https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson')
.then(r => r.json())
.then(geo => {
// Dynamically build A2→A3 mapping from GeoJSON
for (const f of geo.features) {
const a2 = f.properties?.ISO_A2
const a3 = f.properties?.ADM0_A3 || f.properties?.ISO_A3
if (a2 && a3 && a2 !== '-99' && a3 !== '-99' && !A2_TO_A3[a2]) {
A2_TO_A3[a2] = a3
}
}
setGeoData(geo)
})
.catch(() => {})
}, [])
// Load visited regions (geocoded from places/trips) — once on mount
useEffect(() => {
apiClient.get(`/addons/atlas/regions?_t=${Date.now()}`)
.then(r => setVisitedRegions(r.data?.regions || {}))
.catch(() => {})
}, [])
// Load admin-1 GeoJSON for countries visible in the current viewport
const loadRegionsForViewportRef = useRef<() => void>(() => {})
const loadRegionsForViewport = (): void => {
if (!mapInstance.current) return
const bounds = mapInstance.current.getBounds()
const toLoad: string[] = []
for (const [code, layer] of Object.entries(country_layer_by_a2_ref.current)) {
if (regionGeoCache.current[code]) continue
try {
if (bounds.intersects((layer as any).getBounds())) toLoad.push(code)
} catch {}
}
if (!toLoad.length) return
apiClient.get(`/addons/atlas/regions/geo?countries=${toLoad.join(',')}`)
.then(geoRes => {
const geo = geoRes.data
if (!geo?.features) return
let added = false
for (const c of toLoad) {
const features = geo.features.filter((f: any) => f.properties?.iso_a2?.toUpperCase() === c)
if (features.length > 0) { regionGeoCache.current[c] = { type: 'FeatureCollection', features }; added = true }
}
if (added) setRegionGeoLoaded(v => v + 1)
})
.catch(() => {})
}
loadRegionsForViewportRef.current = loadRegionsForViewport
// Initialize map — runs after loading is done and mapRef is available
useEffect(() => {
if (loading || !mapRef.current) return
if (mapInstance.current) { mapInstance.current.remove(); mapInstance.current = null }
const map = L.map(mapRef.current, {
center: [25, 0],
zoom: 3,
minZoom: 3,
maxZoom: 10,
zoomControl: false,
attributionControl: false,
maxBounds: [[-90, -220], [90, 220]],
maxBoundsViscosity: 1.0,
fadeAnimation: false,
preferCanvas: true,
})
L.control.zoom({ position: 'bottomright' }).addTo(map)
const tileUrl = dark
? 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png'
L.tileLayer(tileUrl, {
maxZoom: 10,
keepBuffer: 25,
updateWhenZooming: true,
updateWhenIdle: false,
tileSize: 256,
zoomOffset: 0,
crossOrigin: true,
referrerPolicy: 'strict-origin-when-cross-origin',
} as any).addTo(map)
// Preload adjacent zoom level tiles
L.tileLayer(tileUrl, {
maxZoom: 10,
keepBuffer: 10,
opacity: 0,
tileSize: 256,
crossOrigin: true,
referrerPolicy: 'strict-origin-when-cross-origin',
}).addTo(map)
// Custom pane for region layer — above overlay (z-index 400)
map.createPane('regionPane')
map.getPane('regionPane')!.style.zIndex = '401'
mapInstance.current = map
// Zoom-based region switching
map.on('zoomend', () => {
const z = map.getZoom()
const shouldShow = z >= 5
setShowRegions(shouldShow)
const overlayPane = map.getPane('overlayPane')
if (overlayPane) {
overlayPane.style.opacity = shouldShow ? '0.35' : '1'
overlayPane.style.pointerEvents = shouldShow ? 'none' : 'auto'
}
if (shouldShow) {
// Re-add region layer if it was removed while zoomed out
if (regionLayerRef.current && !map.hasLayer(regionLayerRef.current)) {
regionLayerRef.current.addTo(map)
}
loadRegionsForViewportRef.current()
} else {
// Physically remove region layer so its SVG paths can't intercept events
if (regionTooltipRef.current) regionTooltipRef.current.style.display = 'none'
if (regionLayerRef.current && map.hasLayer(regionLayerRef.current)) {
regionLayerRef.current.resetStyle()
regionLayerRef.current.removeFrom(map)
}
}
})
map.on('moveend', () => {
if (map.getZoom() >= 6) loadRegionsForViewportRef.current()
})
return () => { map.remove(); mapInstance.current = null }
}, [dark, loading])
// Render GeoJSON countries
useEffect(() => {
if (!mapInstance.current || !geoData || !data) return
const visitedA3 = new Set(data.countries.map(c => A2_TO_A3[c.code]).filter(Boolean))
const countryMap = {}
data.countries.forEach(c => { if (A2_TO_A3[c.code]) countryMap[A2_TO_A3[c.code]] = c })
// Preserve current map view
const currentCenter = mapInstance.current.getCenter()
const currentZoom = mapInstance.current.getZoom()
if (geoLayerRef.current) {
mapInstance.current.removeLayer(geoLayerRef.current)
}
// Generate deterministic color per country code
const VISITED_COLORS = ['#6366f1','#ec4899','#14b8a6','#f97316','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#e11d48','#0d9488','#7c3aed','#2563eb','#dc2626','#059669','#d946ef']
// Assign colors in order of visit (by index in countries array) so no two neighbors share a color easily
const visitedA3List = [...visitedA3]
const colorMap = {}
visitedA3List.forEach((a3, i) => { colorMap[a3] = VISITED_COLORS[i % VISITED_COLORS.length] })
const colorForCode = (a3) => colorMap[a3] || VISITED_COLORS[0]
const canvasRenderer = L.canvas({ padding: 0.5, tolerance: 5 })
geoLayerRef.current = L.geoJSON(geoData, {
renderer: canvasRenderer,
interactive: true,
bubblingMouseEvents: false,
style: (feature) => {
const a3 = feature.properties?.ADM0_A3 || feature.properties?.ISO_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id
const visited = visitedA3.has(a3)
return {
fillColor: visited ? colorForCode(a3) : (dark ? '#1e1e2e' : '#e2e8f0'),
fillOpacity: visited ? 0.7 : 0.3,
color: dark ? '#333' : '#cbd5e1',
weight: 0.5,
}
},
onEachFeature: (feature, layer) => {
const a3 = feature.properties?.ADM0_A3 || feature.properties?.ISO_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id
const c = countryMap[a3]
if (c) {
country_layer_by_a2_ref.current[c.code] = layer
const name = resolveName(c.code)
const formatDate = (d) => { if (!d) return '—'; const dt = new Date(d); return dt.toLocaleDateString(getLocaleForLanguage(language), { month: 'short', year: 'numeric' }) }
const tooltipHtml = `
<div style="display:flex;flex-direction:column;gap:8px;min-width:160px">
<div style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.1em;padding-bottom:6px;border-bottom:1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}">${name}</div>
<div style="display:flex;gap:14px">
<div><span style="font-size:16px;font-weight:800">${c.tripCount}</span> <span style="font-size:10px;opacity:0.5;text-transform:uppercase;letter-spacing:0.05em">${c.tripCount === 1 ? t('atlas.tripSingular') : t('atlas.tripPlural')}</span></div>
<div><span style="font-size:16px;font-weight:800">${c.placeCount}</span> <span style="font-size:10px;opacity:0.5;text-transform:uppercase;letter-spacing:0.05em">${c.placeCount === 1 ? t('atlas.placeVisited') : t('atlas.placesVisited')}</span></div>
</div>
<div style="display:flex;gap:2px;border-top:1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'};padding-top:8px">
<div style="flex:1;display:flex;flex-direction:column;gap:2px">
<span style="font-size:9px;text-transform:uppercase;letter-spacing:0.08em;opacity:0.4">${t('atlas.firstVisit')}</span>
<span style="font-size:12px;font-weight:700">${formatDate(c.firstVisit)}</span>
</div>
<div style="flex:1;display:flex;flex-direction:column;gap:2px">
<span style="font-size:9px;text-transform:uppercase;letter-spacing:0.08em;opacity:0.4">${t('atlas.lastVisitLabel')}</span>
<span style="font-size:12px;font-weight:700">${formatDate(c.lastVisit)}</span>
</div>
</div>
</div>
</div>`
layer.bindTooltip(tooltipHtml, {
sticky: false, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
})
layer.on('click', () => {
if (c.placeCount === 0 && c.tripCount === 0) {
handleUnmarkCountry(c.code)
}
})
layer.on('mouseover', (e) => {
e.target.setStyle({ fillOpacity: 0.9, weight: 2, color: dark ? '#818cf8' : '#4f46e5' })
})
layer.on('mouseout', (e) => {
geoLayerRef.current.resetStyle(e.target)
})
} else {
// Unvisited country — allow clicking to mark as visited
// Reverse lookup: find A2 code from A3, or use A3 directly
const a3ToA2Entry = Object.entries(A2_TO_A3).find(([, v]) => v === a3)
const isoA2 = feature.properties?.ISO_A2
const countryCode = a3ToA2Entry ? a3ToA2Entry[0] : (isoA2 && isoA2 !== '-99' ? isoA2 : null)
if (countryCode && countryCode !== '-99') {
country_layer_by_a2_ref.current[countryCode] = layer
const name = feature.properties?.NAME || feature.properties?.ADMIN || resolveName(countryCode)
layer.bindTooltip(`<div style="font-size:12px;font-weight:600">${name}</div>`, {
sticky: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
})
layer.on('click', () => handleMarkCountry(countryCode, name))
layer.on('mouseover', (e) => {
e.target.setStyle({ fillOpacity: 0.5, weight: 1.5, color: dark ? '#555' : '#94a3b8' })
})
layer.on('mouseout', (e) => {
geoLayerRef.current.resetStyle(e.target)
})
}
}
}
}).addTo(mapInstance.current)
// Restore map view after re-render
mapInstance.current.setView(currentCenter, currentZoom, { animate: false })
}, [geoData, data, dark])
// Render sub-national region layer (zoom >= 5)
useEffect(() => {
if (!mapInstance.current) return
// Remove existing region layer
if (regionLayerRef.current) {
mapInstance.current.removeLayer(regionLayerRef.current)
regionLayerRef.current = null
}
if (Object.keys(regionGeoCache.current).length === 0) return
// Build set of visited region codes and per-country name sets
const visitedRegionCodes = new Set<string>()
const visitedRegionNamesByCountry = new Map<string, Set<string>>()
const regionPlaceCounts: Record<string, number> = {}
for (const [countryCode, regions] of Object.entries(visitedRegions)) {
const names = new Set<string>()
for (const r of regions) {
visitedRegionCodes.add(r.code)
names.add(r.name.toLowerCase())
regionPlaceCounts[r.code] = r.placeCount
regionPlaceCounts[`${countryCode}:${r.name.toLowerCase()}`] = r.placeCount
}
visitedRegionNamesByCountry.set(countryCode, names)
}
// Match feature by ISO code OR region name scoped to the feature's country
const isVisitedFeature = (f: any) => {
if (visitedRegionCodes.has(f.properties?.iso_3166_2)) return true
const countryA2 = (f.properties?.iso_a2 || '').toUpperCase()
const countryNames = visitedRegionNamesByCountry.get(countryA2)
if (!countryNames) return false
const name = (f.properties?.name || '').toLowerCase()
if (countryNames.has(name)) return true
const nameEn = (f.properties?.name_en || '').toLowerCase()
if (nameEn && countryNames.has(nameEn)) return true
return false
}
// Include ALL region features — visited ones get colored fill, unvisited get outline only
const allFeatures: any[] = []
for (const geo of Object.values(regionGeoCache.current)) {
for (const f of geo.features) {
allFeatures.push(f)
}
}
if (allFeatures.length === 0) return
// Use same colors as country layer
const VISITED_COLORS = ['#6366f1','#ec4899','#14b8a6','#f97316','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#e11d48','#0d9488','#7c3aed','#2563eb','#dc2626','#059669','#d946ef']
const countryA3Set = data ? data.countries.map(c => A2_TO_A3[c.code]).filter(Boolean) : []
const countryColorMap: Record<string, string> = {}
countryA3Set.forEach((a3, i) => { countryColorMap[a3] = VISITED_COLORS[i % VISITED_COLORS.length] })
// Map country A2 code to country color
const a2ColorMap: Record<string, string> = {}
if (data) data.countries.forEach(c => { if (A2_TO_A3[c.code] && countryColorMap[A2_TO_A3[c.code]]) a2ColorMap[c.code] = countryColorMap[A2_TO_A3[c.code]] })
const mergedGeo = { type: 'FeatureCollection', features: allFeatures }
const svgRenderer = L.svg({ pane: 'regionPane' })
regionLayerRef.current = L.geoJSON(mergedGeo as any, {
renderer: svgRenderer,
interactive: true,
pane: 'regionPane',
style: (feature) => {
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
const visited = isVisitedFeature(feature)
return visited ? {
fillColor: a2ColorMap[countryA2] || '#6366f1',
fillOpacity: 0.85,
color: dark ? '#888' : '#64748b',
weight: 1.2,
} : {
fillColor: dark ? '#ffffff' : '#000000',
fillOpacity: 0.03,
color: dark ? '#555' : '#94a3b8',
weight: 1,
}
},
onEachFeature: (feature, layer) => {
const regionName = feature?.properties?.name || ''
const regionNameEn = feature?.properties?.name_en || ''
const countryName = feature?.properties?.admin || ''
const regionCode = feature?.properties?.iso_3166_2 || ''
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
const visited = isVisitedFeature(feature)
const count = regionPlaceCounts[regionCode] || regionPlaceCounts[`${countryA2}:${regionName.toLowerCase()}`] || regionPlaceCounts[`${countryA2}:${regionNameEn.toLowerCase()}`] || 0
layer.on('click', () => {
if (!countryA2) return
if (visited) {
const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode || r.name.toLowerCase() === regionNameEn.toLowerCase())
if (regionEntry?.manuallyMarked) {
setConfirmActionRef.current({
type: 'unmark-region',
code: countryA2,
name: regionName,
regionCode,
countryName,
})
} else {
loadCountryDetailRef.current(countryA2)
}
} else {
setConfirmActionRef.current({
type: 'choose-region',
code: countryA2, // country A2 code — used for flag display
name: regionName, // region name — shown as heading
regionCode,
countryName,
})
}
})
layer.on('mouseover', (e: any) => {
e.target.setStyle(visited
? { fillOpacity: 0.95, weight: 2, color: dark ? '#818cf8' : '#4f46e5' }
: { fillOpacity: 0.15, fillColor: dark ? '#818cf8' : '#4f46e5', weight: 1.5, color: dark ? '#818cf8' : '#4f46e5' }
)
const tt = regionTooltipRef.current
if (tt) {
tt.style.display = 'block'
tt.style.left = e.originalEvent.clientX + 12 + 'px'
tt.style.top = e.originalEvent.clientY - 10 + 'px'
tt.innerHTML = visited
? `<div style="font-weight:600;margin-bottom:3px">${regionName}</div><div style="opacity:0.5;font-size:10px">${countryName}</div><div style="margin-top:5px;font-size:11px"><b>${count}</b> ${count === 1 ? 'place' : 'places'}</div>`
: `<div style="font-weight:600;margin-bottom:3px">${regionName}</div><div style="opacity:0.5;font-size:10px">${countryName}</div>`
}
})
layer.on('mousemove', (e: any) => {
const tt = regionTooltipRef.current
if (tt) { tt.style.left = e.originalEvent.clientX + 12 + 'px'; tt.style.top = e.originalEvent.clientY - 10 + 'px' }
})
layer.on('mouseout', (e: any) => {
regionLayerRef.current?.resetStyle(e.target)
const tt = regionTooltipRef.current
if (tt) tt.style.display = 'none'
})
},
})
// Only add to map if currently in region mode — otherwise hold it ready for when user zooms in
if (mapInstance.current.getZoom() >= 6) {
regionLayerRef.current.addTo(mapInstance.current)
}
}, [regionGeoLoaded, visitedRegions, dark, t])
const handleMarkCountry = (code: string, name: string): void => {
setConfirmAction({ type: 'choose', code, name })
}
handleMarkCountryRef.current = handleMarkCountry
setConfirmActionRef.current = setConfirmAction
const handleUnmarkCountry = (code: string): void => {
const country = data?.countries.find(c => c.code === code)
setConfirmAction({ type: 'unmark', code, name: resolveName(code) })
}
const select_country_from_search = (country_code: string): void => {
const country_label = resolveName(country_code)
set_atlas_country_search(country_label)
set_atlas_country_open(false)
set_atlas_country_results([])
const layer = country_layer_by_a2_ref.current[country_code]
try {
if (layer?.getBounds && mapInstance.current) {
mapInstance.current.fitBounds(layer.getBounds(), { padding: [24, 24], animate: true, maxZoom: 6 })
}
} catch (e ) {
console.error('Error fitting bounds', e)
}
setConfirmAction({ type: 'choose', code: country_code, name: country_label })
}
const executeConfirmAction = async (): Promise<void> => {
if (!confirmAction) return
const { type, code } = confirmAction
setConfirmAction(null)
// Update local state immediately (no API reload = no map re-render flash)
if (type === 'mark') {
apiClient.post(`/addons/atlas/country/${code}/mark`).catch(() => {})
setData(prev => {
if (!prev || prev.countries.find(c => c.code === code)) return prev
return {
...prev,
countries: [...prev.countries, { code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }],
stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 },
}
})
} else {
apiClient.delete(`/addons/atlas/country/${code}/mark`).catch(() => {})
setSelectedCountry(null)
setCountryDetail(null)
setData(prev => {
if (!prev) return prev
const c = prev.countries.find(c => c.code === code)
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
return {
...prev,
countries: prev.countries.filter(c => c.code !== code),
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
}
})
setVisitedRegions(prev => {
if (!prev[code]) return prev
const next = { ...prev }
delete next[code]
return next
})
}
}
const handleAddBucketItem = async (): Promise<void> => {
if (!bucketForm.name.trim()) return
try {
const data: Record<string, unknown> = { name: bucketForm.name.trim() }
if (bucketForm.notes.trim()) data.notes = bucketForm.notes.trim()
if (bucketForm.lat && bucketForm.lng) { data.lat = parseFloat(bucketForm.lat); data.lng = parseFloat(bucketForm.lng) }
const targetDate = bucketForm.target_date || (bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null)
if (targetDate) data.target_date = targetDate
const r = await apiClient.post('/addons/atlas/bucket-list', data)
setBucketList(prev => [r.data.item, ...prev])
setBucketForm({ name: '', notes: '', lat: '', lng: '', target_date: '' })
setBucketSearch(''); setBucketSearchResults([]); setBucketPoiMonth(0); setBucketPoiYear(0)
setShowBucketAdd(false)
} catch { /* */ }
}
const handleDeleteBucketItem = async (id: number): Promise<void> => {
try {
await apiClient.delete(`/addons/atlas/bucket-list/${id}`)
setBucketList(prev => prev.filter(i => i.id !== id))
} catch { /* */ }
}
const handleBucketPoiSearch = async () => {
if (!bucketSearch.trim()) return
setBucketSearching(true)
try {
const result = await mapsApi.search(bucketSearch, language)
setBucketSearchResults(result.places || [])
} catch {} finally { setBucketSearching(false) }
}
const handleSelectBucketPoi = (result: any) => {
const targetDate = bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null
setBucketForm({
name: result.name || bucketSearch,
notes: '',
lat: String(result.lat || ''),
lng: String(result.lng || ''),
target_date: targetDate || '',
})
setBucketSearchResults([])
setBucketSearch('')
}
// Render bucket list markers on map
useEffect(() => {
if (!mapInstance.current) return
if (bucketMarkersRef.current) {
mapInstance.current.removeLayer(bucketMarkersRef.current)
}
if (bucketList.length === 0) return
const markers = bucketList.filter(b => b.lat && b.lng).map(b => {
const icon = L.divIcon({
className: '',
html: `<div style="width:28px;height:28px;border-radius:50%;background:rgba(251,191,36,0.9);display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,0.3);border:2px solid white"><svg width="14" height="14" viewBox="0 0 24 24" fill="white" stroke="none"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></div>`,
iconSize: [28, 28],
iconAnchor: [14, 14],
})
return L.marker([b.lat!, b.lng!], { icon }).bindTooltip(
`<div style="font-size:12px;font-weight:600">${b.name}</div>${b.notes ? `<div style="font-size:10px;opacity:0.7;margin-top:2px">${b.notes}</div>` : ''}`,
{ className: 'atlas-tooltip', direction: 'top', offset: [0, -14] }
)
})
bucketMarkersRef.current = L.layerGroup(markers).addTo(mapInstance.current)
}, [bucketList])
const loadCountryDetail = async (code: string): Promise<void> => {
setSelectedCountry(code)
try {
const r = await apiClient.get(`/addons/atlas/country/${code}`)
setCountryDetail(r.data)
} catch { /* */ }
}
loadCountryDetailRef.current = loadCountryDetail
const stats = data?.stats || { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 }
const countries = data?.countries || []
return {
t, language, navigate, resolveName, dark, loading,
mapRef, regionTooltipRef, panelRef, glareRef, borderGlareRef,
handlePanelMouseMove, handlePanelMouseLeave,
data, setData, stats, countries, selectedCountry, countryDetail,
loadCountryDetail, handleUnmarkCountry, select_country_from_search,
visitedRegions, setVisitedRegions,
atlas_country_search, set_atlas_country_search,
atlas_country_results, set_atlas_country_results,
atlas_country_open, set_atlas_country_open, atlas_country_options,
confirmAction, setConfirmAction, executeConfirmAction,
bucketMonth, setBucketMonth, bucketYear, setBucketYear,
bucketList, setBucketList, bucketTab, setBucketTab,
showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm,
handleAddBucketItem, handleDeleteBucketItem, handleBucketPoiSearch, handleSelectBucketPoi,
bucketSearchResults, setBucketSearchResults,
bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear,
bucketSearching, bucketSearch, setBucketSearch,
}
}
@@ -0,0 +1,72 @@
/**
* Dashboard data model + pure helpers shared by the data hook (useDashboard)
* and the presentational components in DashboardPage. Kept free of React/IO so
* both sides can import it without a cycle. Part of the FE "page = wiring
* container + data hook" convention (see dashboard/README.md).
*/
export interface DashboardTrip {
id: number
title: string
description?: string | null
start_date?: string | null
end_date?: string | null
cover_image?: string | null
is_archived?: boolean
is_owner?: boolean
owner_username?: string
day_count?: number
place_count?: number
shared_count?: number
[key: string]: string | number | boolean | null | undefined
}
export interface Member { id: number; username: string; avatar_url?: string | null }
export interface Place {
id: number; name: string; image_url: string | null; lat: number | null; lng: number | null
google_place_id: string | null; osm_id: string | null
category_color?: string | null; category_icon?: string | null
}
export interface HeroBundle { members: Member[]; places: Place[] }
export interface TravelStats { totalTrips?: number; totalDays?: number; totalPlaces?: number; totalDistanceKm?: number; countries?: string[] }
export interface UpcomingReservation {
id: number; trip_id: number; title: string; type: string
reservation_time?: string | null; day_date?: string | null
location?: string | null; place_name?: string | null; trip_title?: string | null
}
export const MS_PER_DAY = 86400000
export function daysUntil(dateStr: string | null | undefined): number | null {
if (!dateStr) return null
const today = new Date(); today.setHours(0, 0, 0, 0)
const d = new Date(dateStr + 'T00:00:00'); d.setHours(0, 0, 0, 0)
return Math.round((d.getTime() - today.getTime()) / MS_PER_DAY)
}
export function getTripStatus(trip: DashboardTrip): 'ongoing' | 'today' | 'tomorrow' | 'future' | 'past' | null {
const today = new Date().toISOString().split('T')[0]
if (trip.start_date && trip.end_date && trip.start_date <= today && trip.end_date >= today) return 'ongoing'
const until = daysUntil(trip.start_date)
if (until === null) return null
if (until === 0) return 'today'
if (until === 1) return 'tomorrow'
if (until > 1) return 'future'
return 'past'
}
export function sortTrips(trips: DashboardTrip[]): DashboardTrip[] {
const today = new Date().toISOString().split('T')[0]
const rank = (t: DashboardTrip) => {
if (t.start_date && t.end_date && t.start_date <= today && t.end_date >= today) return 0
if (t.start_date && t.start_date >= today) return 1
return 2
}
return [...trips].sort((a, b) => {
const ra = rank(a), rb = rank(b)
if (ra !== rb) return ra - rb
const ad = a.start_date || '', bd = b.start_date || ''
if (ra <= 1) return ad.localeCompare(bd)
return bd.localeCompare(ad)
})
}
+186
View File
@@ -0,0 +1,186 @@
import { useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { tripsApi, authApi, reservationsApi } from '../../api/client'
import { tripRepo } from '../../repo/tripRepo'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import { useToast } from '../../components/shared/Toast'
import { getApiErrorMessage } from '../../types'
import {
type DashboardTrip,
type TravelStats,
type UpcomingReservation,
type HeroBundle,
getTripStatus,
sortTrips,
} from './dashboardModel'
/**
* Dashboard data hook owns every bit of the page's state, data loading and
* mutations (trip CRUD, archive/copy, travel stats, upcoming reservations,
* the spotlight hero bundle) and exposes derived values + handlers. The
* DashboardPage component is a pure wiring container that renders what this
* returns. Behaviour is identical to the previous in-component logic.
*/
export function useDashboard() {
const [trips, setTrips] = useState<DashboardTrip[]>([])
const [archivedTrips, setArchivedTrips] = useState<DashboardTrip[]>([])
const [isLoading, setIsLoading] = useState<boolean>(true)
const [showForm, setShowForm] = useState<boolean>(false)
const [editingTrip, setEditingTrip] = useState<DashboardTrip | null>(null)
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid')
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
const [tripFilter, setTripFilter] = useState<'planned' | 'archive' | 'completed'>('planned')
const [stats, setStats] = useState<TravelStats | null>(null)
const [upcoming, setUpcoming] = useState<UpcomingReservation[]>([])
const [heroBundle, setHeroBundle] = useState<HeroBundle | null>(null)
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const toast = useToast()
const { t, locale } = useTranslation()
const { demoMode } = useAuthStore()
const toggleViewMode = () => {
setViewMode(prev => {
const next = prev === 'grid' ? 'list' : 'grid'
localStorage.setItem('trek_dashboard_view', next)
return next
})
}
useEffect(() => {
if (searchParams.get('create') === '1') {
setShowForm(true)
setSearchParams({}, { replace: true })
}
}, [searchParams])
useEffect(() => { loadTrips() }, [])
// Travel stats + upcoming reservations power the atlas row and the sidebar.
// Both are best-effort: a failure just leaves that section empty.
useEffect(() => {
authApi.travelStats().then(setStats).catch(() => {})
reservationsApi.upcoming().then((r: { reservations: UpcomingReservation[] }) => setUpcoming(r.reservations || [])).catch(() => {})
}, [])
const loadTrips = async () => {
setIsLoading(true)
try {
const { trips, archivedTrips } = await tripRepo.list()
setTrips(sortTrips(trips))
setArchivedTrips(sortTrips(archivedTrips))
} catch {
toast.error(t('dashboard.toast.loadError'))
} finally {
setIsLoading(false)
}
}
const today = new Date().toISOString().split('T')[0]
const spotlight = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today)
|| trips.find(t => t.start_date && t.start_date >= today)
|| trips[0]
|| null
const rest = spotlight ? trips.filter(t => t.id !== spotlight.id) : trips
// Pull the spotlight trip's members + places so the boarding pass can show
// real buddies and place thumbnails instead of placeholders.
useEffect(() => {
if (!spotlight) { setHeroBundle(null); return }
let cancelled = false
tripsApi.bundle(spotlight.id)
.then((b: HeroBundle) => { if (!cancelled) setHeroBundle({ members: b.members || [], places: b.places || [] }) })
.catch(() => { if (!cancelled) setHeroBundle(null) })
return () => { cancelled = true }
}, [spotlight?.id])
const handleCreate = async (tripData: Record<string, unknown>) => {
try {
const data = await tripsApi.create(tripData)
setTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.created'))
return data
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, t('dashboard.toast.createError')))
}
}
const handleUpdate = async (tripData: Record<string, unknown>) => {
if (!editingTrip) return
try {
const data = await tripsApi.update(editingTrip.id, tripData)
setTrips(prev => sortTrips(prev.map(t => t.id === editingTrip.id ? data.trip : t)))
toast.success(t('dashboard.toast.updated'))
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, t('dashboard.toast.updateError')))
}
}
const confirmDelete = async () => {
if (!deleteTrip) return
try {
await tripsApi.delete(deleteTrip.id)
setTrips(prev => prev.filter(t => t.id !== deleteTrip.id))
setArchivedTrips(prev => prev.filter(t => t.id !== deleteTrip.id))
toast.success(t('dashboard.toast.deleted'))
} catch {
toast.error(t('dashboard.toast.deleteError'))
}
setDeleteTrip(null)
}
const handleArchive = async (id: number) => {
try {
const data = await tripsApi.archive(id)
setTrips(prev => prev.filter(t => t.id !== id))
setArchivedTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.archived'))
} catch {
toast.error(t('dashboard.toast.archiveError'))
}
}
const handleUnarchive = async (id: number) => {
try {
const data = await tripsApi.unarchive(id)
setArchivedTrips(prev => prev.filter(t => t.id !== id))
setTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.restored'))
} catch {
toast.error(t('dashboard.toast.restoreError'))
}
}
const confirmCopy = async () => {
if (!copyTrip) return
try {
const data = await tripsApi.copy(copyTrip.id, { title: `${copyTrip.title} (${t('dashboard.copySuffix')})` })
setTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.copied'))
} catch {
toast.error(t('dashboard.toast.copyError'))
}
setCopyTrip(null)
}
const gridTrips = tripFilter === 'archive' ? archivedTrips
: tripFilter === 'completed' ? rest.filter(t => getTripStatus(t) === 'past')
: rest.filter(t => getTripStatus(t) !== 'past')
return {
// cross-cutting
demoMode, locale, t, navigate,
// data + derived
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
// ui state
tripFilter, setTripFilter, viewMode, toggleViewMode,
showForm, setShowForm, editingTrip, setEditingTrip,
deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, setTrips,
// actions
handleCreate, handleUpdate, confirmDelete, handleArchive, handleUnarchive, confirmCopy,
}
}
+57
View File
@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useTripStore } from '../../store/tripStore'
import { tripRepo } from '../../repo/tripRepo'
import { placeRepo } from '../../repo/placeRepo'
import type { Trip, Place, TripFile } from '../../types'
/**
* Files page data hook owns the trip/places load, the file sync from the trip
* store and the upload/delete handlers. FilesPage is a pure wiring container.
* Behaviour is identical to the previous in-component logic.
*/
export function useFiles() {
const { id: tripId } = useParams<{ id: string }>()
const navigate = useNavigate()
const tripStore = useTripStore()
const [trip, setTrip] = useState<Trip | null>(null)
const [places, setPlaces] = useState<Place[]>([])
const [files, setFiles] = useState<TripFile[]>([])
const [isLoading, setIsLoading] = useState<boolean>(true)
useEffect(() => {
loadData()
}, [tripId])
const loadData = async (): Promise<void> => {
setIsLoading(true)
try {
const [tripData, placesData] = await Promise.all([
tripRepo.get(tripId),
placeRepo.list(tripId),
])
setTrip(tripData.trip)
setPlaces(placesData.places)
await tripStore.loadFiles(tripId)
} catch (err: unknown) {
navigate('/dashboard')
} finally {
setIsLoading(false)
}
}
useEffect(() => {
setFiles(tripStore.files)
}, [tripStore.files])
const handleUpload = async (formData: FormData): Promise<void> => {
await tripStore.addFile(tripId, formData)
}
const handleDelete = async (fileId: number): Promise<void> => {
await tripStore.deleteFile(tripId, fileId)
}
return { tripId, navigate, trip, places, files, isLoading, handleUpload, handleDelete }
}
@@ -0,0 +1,43 @@
import { useState, useEffect, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { authApi } from '../../api/client'
/**
* Forgot-password data hook owns the form state, the SMTP-availability probe
* and the enumeration-safe submit. ForgotPasswordPage is a pure wiring
* container that renders what this returns. Behaviour is identical to the
* previous in-component logic.
*/
export function useForgotPassword() {
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [submitted, setSubmitted] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [smtpConfigured, setSmtpConfigured] = useState<boolean | null>(null)
useEffect(() => {
// Probe whether SMTP is configured so we can warn the user up-front that the
// link will land in the server console instead of their inbox. Null while
// pending — hint is hidden until we know.
authApi.getAppConfig?.()
.then((cfg: { available_channels?: { email?: boolean } } | null) => {
setSmtpConfigured(!!cfg?.available_channels?.email)
})
.catch(() => setSmtpConfigured(null))
}, [])
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (isLoading) return
setIsLoading(true)
try {
await authApi.forgotPassword({ email: email.trim() })
} catch {
// Enumeration-safe: success UX regardless of server outcome.
}
setSubmitted(true)
setIsLoading(false)
}
return { navigate, email, setEmail, submitted, isLoading, smtpConfigured, handleSubmit }
}
@@ -0,0 +1,47 @@
import { useEffect, useRef, useState } from 'react'
import { useInAppNotificationStore } from '../../store/inAppNotificationStore.ts'
/**
* In-app notifications data hook owns the store wiring, the unread-only
* filter, the initial + filter-change fetches and the infinite-scroll observer.
* InAppNotificationsPage is a pure wiring container. Behaviour is identical to
* the previous in-component logic.
*/
export function useInAppNotifications() {
const { notifications, unreadCount, total, isLoading, hasMore, fetchNotifications, markAllRead, deleteAll } = useInAppNotificationStore()
const [unreadOnly, setUnreadOnly] = useState(false)
const loaderRef = useRef<HTMLDivElement>(null)
useEffect(() => {
fetchNotifications(true)
}, [])
// Reload when filter changes
useEffect(() => {
// We need to fetch with the unreadOnly filter — re-fetch from scratch
// The store fetchNotifications doesn't take a filter param directly,
// so we use the API directly for filtered view via a side channel.
// For now, reset and fetch — store always loads all, filter is client-side.
fetchNotifications(true)
}, [unreadOnly])
// Infinite scroll
useEffect(() => {
if (!loaderRef.current) return
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && hasMore && !isLoading) {
fetchNotifications(false)
}
}, { threshold: 0.1 })
observer.observe(loaderRef.current)
return () => observer.disconnect()
}, [hasMore, isLoading])
const displayed = unreadOnly ? notifications.filter(n => !n.is_read) : notifications
return {
notifications, unreadCount, total, isLoading, hasMore,
unreadOnly, setUnreadOnly, loaderRef, displayed,
markAllRead, deleteAll,
}
}
+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useState, useMemo, useRef } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useJourneyStore } from '../../store/journeyStore'
import { journeyApi } from '../../api/client'
import { useToast } from '../../components/shared/Toast'
import { useTranslation } from '../../i18n'
import { computeJourneyLifecycle } from '../../utils/journeyLifecycle'
/**
* Journey list page logic owns the journey store load, the create-journey
* modal (title, available trips, selection), the search box, the trip
* suggestion banner and the active/filtered journey derivations. JourneyPage
* stays a wiring container around its hero/grid/modal JSX and JourneyCard.
* Behaviour is identical to the previous in-component logic.
*/
export function useJourney() {
const navigate = useNavigate()
const toast = useToast()
const { t } = useTranslation()
const { journeys, loading, loadJourneys, createJourney } = useJourneyStore()
const [showCreate, setShowCreate] = useState(false)
const [newTitle, setNewTitle] = useState('')
const [availableTrips, setAvailableTrips] = useState<any[]>([])
const [selectedTripIds, setSelectedTripIds] = useState<Set<number>>(new Set())
const [searchOpen, setSearchOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const searchInputRef = useRef<HTMLInputElement>(null)
// suggestion
const [suggestions, setSuggestions] = useState<any[]>([])
const [dismissedSuggestions, setDismissedSuggestions] = useState<Set<number>>(new Set())
const [searchParams, setSearchParams] = useSearchParams()
useEffect(() => {
loadJourneys()
journeyApi.suggestions().then(d => setSuggestions(d.trips || [])).catch(() => {})
}, [])
// The bottom-nav "+" opens the new-journey modal via ?create=1.
useEffect(() => {
if (searchParams.get('create') === '1') {
openCreateModal()
setSearchParams(p => { p.delete('create'); return p }, { replace: true })
}
}, [searchParams])
const activeSuggestion = suggestions.find(s => !dismissedSuggestions.has(s.id))
const activeJourney = useMemo(() => {
if (searchQuery.trim()) return null
return journeys.find(j => {
const j2 = j as any
return computeJourneyLifecycle(j.status, j2.trip_date_min, j2.trip_date_max) === 'live'
}) || null
}, [journeys, searchQuery])
const filteredJourneys = useMemo(() => {
const q = searchQuery.trim().toLowerCase()
if (!q) return journeys.filter(j => j.id !== activeJourney?.id)
return journeys.filter(j => {
const inTitle = j.title.toLowerCase().includes(q)
const inSubtitle = j.subtitle?.toLowerCase().includes(q) ?? false
return inTitle || inSubtitle
})
}, [journeys, activeJourney, searchQuery])
const openCreateModal = async (preSelectedTripId?: number) => {
setShowCreate(true)
setNewTitle('')
const initial = new Set<number>()
if (preSelectedTripId) initial.add(preSelectedTripId)
setSelectedTripIds(initial)
try {
const data = await journeyApi.availableTrips()
setAvailableTrips(data.trips || [])
} catch {}
}
const handleCreate = async () => {
if (!newTitle.trim()) return
try {
const j = await createJourney({
title: newTitle.trim(),
trip_ids: [...selectedTripIds],
})
setShowCreate(false)
navigate(`/journey/${j.id}`)
} catch {
toast.error(t('journey.createError'))
}
}
const totalPlaces = useMemo(() => {
return availableTrips.filter(t => selectedTripIds.has(t.id)).reduce((sum: number, t: any) => sum + (t.place_count || 0), 0)
}, [availableTrips, selectedTripIds])
return {
navigate, journeys, loading,
showCreate, setShowCreate, newTitle, setNewTitle,
availableTrips, selectedTripIds, setSelectedTripIds,
searchOpen, setSearchOpen, searchQuery, setSearchQuery, searchInputRef,
activeSuggestion, setDismissedSuggestions,
activeJourney, filteredJourneys,
openCreateModal, handleCreate, totalPlaces,
}
}
@@ -0,0 +1,290 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { useJourneyStore } from '../../store/journeyStore'
import { useTranslation } from '../../i18n'
import { addListener, removeListener } from '../../api/websocket'
import { DAY_COLORS } from '../../components/Journey/dayColors'
import type { JourneyMapAutoHandle as JourneyMapHandle } from '../../components/Journey/JourneyMapAuto'
import { useToast } from '../../components/shared/Toast'
import { useIsMobile } from '../../hooks/useIsMobile'
import type { JourneyEntry } from '../../store/journeyStore'
/**
* Journey detail page logic owns the journey load + WebSocket live sync, the
* timeline/gallery view state, the entry editor/viewer/delete/lightbox dialogs,
* the scroll-synced sticky map (marker click + located-entry tracking) and the
* map/trip-date derivations. JourneyDetailPage stays a wiring container around
* its large two-pane JSX and many presentational sub-components.
* Behaviour is identical to the previous in-component logic.
*/
export function useJourneyDetail() {
const { id } = useParams()
const navigate = useNavigate()
const toast = useToast()
const { t, locale } = useTranslation()
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto } = useJourneyStore()
const mapRef = useRef<JourneyMapHandle>(null)
const fullMapRef = useRef<JourneyMapHandle>(null)
const [activeLocationId, setActiveLocationId] = useState<string | null>(null)
const isMobile = useIsMobile()
// Role-based permissions (server-provided via my_role). Fall back to
// "owner" when the field isn't present yet (legacy responses) so behavior
// matches the pre-permissions era.
const myRole = (current as any)?.my_role ?? 'owner'
const canEditEntries = myRole === 'owner' || myRole === 'editor'
const canEditJourney = myRole === 'owner'
const [view, setView] = useState<'timeline' | 'gallery'>('timeline')
const [activeEntryId, setActiveEntryId] = useState<string | null>(null)
const feedRef = useRef<HTMLDivElement>(null)
const [viewingEntry, setViewingEntry] = useState<JourneyEntry | null>(null)
const [editingEntry, setEditingEntry] = useState<JourneyEntry | null>(null)
const [lightbox, setLightbox] = useState<{ photos: { id: number; src: string; caption?: string | null; provider?: string; asset_id?: string | null; owner_id?: number | null }[]; index: number } | null>(null)
const [deleteTarget, setDeleteTarget] = useState<JourneyEntry | null>(null)
const [showInvite, setShowInvite] = useState(false)
const [showAddTrip, setShowAddTrip] = useState(false)
const [searchParams, setSearchParams] = useSearchParams()
// The bottom-nav "+" starts a new entry via ?create=entry.
useEffect(() => {
if (searchParams.get('create') === 'entry' && current && canEditEntries) {
const today = new Date().toISOString().slice(0, 10)
setEditingEntry({ id: 0, journey_id: current.id, author_id: 0, type: 'entry', entry_date: today, visibility: 'private', sort_order: 0, photos: [], created_at: 0, updated_at: 0 } as JourneyEntry)
setSearchParams(p => { p.delete('create'); return p }, { replace: true })
}
}, [searchParams, current, canEditEntries])
const [unlinkTrip, setUnlinkTrip] = useState<{ trip_id: number; title: string } | null>(null)
const [showSettings, setShowSettings] = useState(false)
const [hideSkeletons, setHideSkeletons] = useState(false)
useEffect(() => {
if (id) loadJourney(Number(id)).catch(() => {})
}, [id])
useEffect(() => {
if (current?.hide_skeletons !== undefined) setHideSkeletons(current.hide_skeletons)
}, [current?.hide_skeletons])
useEffect(() => {
if (notFound) {
toast.error(t('journey.notFound'))
navigate('/journey')
}
}, [notFound])
// WebSocket real-time updates
useEffect(() => {
if (!id) return
const journeyId = Number(id)
const handler = (event: Record<string, unknown>) => {
const type = event.type as string
if (!type?.startsWith('journey:')) return
if (event.journeyId !== journeyId) return
// reload journey data on any change from other contributors
loadJourney(journeyId)
}
addListener(handler)
return () => removeListener(handler)
}, [id])
// scroll sync with map — the sticky map on the right follows whichever
// entry the user is currently reading in the feed on the left. We use
// scroll position (not IntersectionObserver) because short text-only
// entries pass through any IO band too quickly to reliably register.
const rafRef = useRef<number | null>(null)
const scrollCleanupRef = useRef<(() => void) | null>(null)
// Suppress scroll-sync updates while a programmatic smooth-scroll is
// running (triggered by a marker click). The scroll-progress reference
// line doesn't align with `scrollIntoView({ block: 'center' })`, so the
// sync would otherwise pick random entries as the scroll animates past
// them and end up nowhere near the clicked marker.
const suppressScrollSyncRef = useRef(false)
const suppressTimerRef = useRef<number | null>(null)
const setupScrollSync = useCallback(() => {
scrollCleanupRef.current?.()
const feed = feedRef.current
if (!feed) return
const commitWinner = () => {
if (suppressScrollSyncRef.current) return
const nodes = document.querySelectorAll('[data-entry-id]')
if (nodes.length === 0) return
const feedRect = feed.getBoundingClientRect()
// Reference line tracks scroll progress — at the top of the feed
// it sits at the top edge; at the bottom it sits at the bottom
// edge. This keeps every entry passing through the line exactly
// once even when they're too short to cross a static line before
// the feed runs out of scroll.
const maxScroll = feed.scrollHeight - feed.clientHeight
const progress = maxScroll > 0 ? feed.scrollTop / maxScroll : 0
const referenceY = feedRect.top + feedRect.height * progress
let lastPast: { id: string; top: number } | null = null
let firstAhead: { id: string; top: number } | null = null
nodes.forEach(el => {
const entryId = el.getAttribute('data-entry-id')
if (!entryId) return
const top = el.getBoundingClientRect().top
if (top <= referenceY) {
if (!lastPast || top > lastPast.top) lastPast = { id: entryId, top }
} else {
if (!firstAhead || top < firstAhead.top) firstAhead = { id: entryId, top }
}
})
const winner = lastPast || firstAhead
if (winner) {
setActiveEntryId(winner.id)
if (locatedEntryIdsRef.current.has(winner.id)) {
mapRef.current?.highlightMarker(winner.id)
}
}
}
const onScroll = () => {
if (rafRef.current != null) return
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null
commitWinner()
})
}
feed.addEventListener('scroll', onScroll, { passive: true })
window.addEventListener('scroll', onScroll, { passive: true })
// prime once so the map syncs on initial load
commitWinner()
scrollCleanupRef.current = () => {
feed.removeEventListener('scroll', onScroll)
window.removeEventListener('scroll', onScroll)
if (rafRef.current != null) {
window.cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}
}, [])
useEffect(() => {
if (current?.entries?.length) {
const t = window.setTimeout(setupScrollSync, 300)
return () => {
window.clearTimeout(t)
scrollCleanupRef.current?.()
}
}
return () => scrollCleanupRef.current?.()
}, [current?.entries, setupScrollSync])
const handleMarkerClick = useCallback((entryId: string) => {
const el = document.querySelector(`[data-entry-id="${entryId}"]`)
if (!el) return
// Commit the choice immediately so the highlighted marker stays pinned
// to the clicked entry even while smooth-scroll passes over others.
suppressScrollSyncRef.current = true
setActiveEntryId(entryId)
mapRef.current?.highlightMarker(entryId)
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
if (suppressTimerRef.current != null) window.clearTimeout(suppressTimerRef.current)
// Smooth scroll typically finishes within ~500ms; 750ms gives a safety
// buffer so the sync doesn't snap back to the wrong entry on the very
// last frame.
suppressTimerRef.current = window.setTimeout(() => {
suppressScrollSyncRef.current = false
suppressTimerRef.current = null
}, 750)
}, [])
useEffect(() => () => {
if (suppressTimerRef.current != null) window.clearTimeout(suppressTimerRef.current)
}, [])
const handleLocationClick = useCallback((id: string) => {
setActiveLocationId(id)
}, [])
useEffect(() => {
// give the sidebar map a chance to recalc its size when the view switches
// (feed column width can shift slightly if the gallery vs timeline
// renders with a different scrollbar state).
requestAnimationFrame(() => mapRef.current?.invalidateSize())
}, [view])
// On desktop we run a two-pane layout where only the feed column scrolls;
// the body must not scroll underneath it. Restore on unmount.
useEffect(() => {
if (isMobile) return
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = prev }
}, [isMobile])
// Map only shows real journal entries — skeletons are trip-derived
// suggestions, not something the user actually journaled at that spot.
const mapEntries = useMemo(
() => (current?.entries || []).filter(e =>
e.location_lat && e.location_lng &&
e.title !== 'Gallery' &&
e.title !== '[Trip Photos]' &&
e.type !== 'skeleton'
),
[current?.entries]
)
const sidebarMapItems = useMemo(() => {
const allDates = [...new Set(
(current?.entries || [])
.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]')
.map(e => e.entry_date)
.sort()
)]
const sorted = [...mapEntries].sort((a, b) => a.entry_date.localeCompare(b.entry_date))
const dayCounters = new Map<string, number>()
return sorted.map(e => {
const dayIdx = allDates.indexOf(e.entry_date)
const dayLabel = (dayCounters.get(e.entry_date) ?? 0) + 1
dayCounters.set(e.entry_date, dayLabel)
return {
id: String(e.id),
lat: e.location_lat!,
lng: e.location_lng!,
title: e.title || '',
location_name: e.location_name || '',
mood: e.mood,
created_at: e.entry_date,
entry_date: e.entry_date,
dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length],
dayLabel,
}
})
}, [mapEntries, current?.entries])
const locatedEntryIdsRef = useRef(new Set<string>())
useEffect(() => {
locatedEntryIdsRef.current = new Set(sidebarMapItems.map(m => m.id))
}, [sidebarMapItems])
const tripDates = useMemo(() => {
const dates = new Set<string>()
if (!current?.trips) return dates
for (const trip of current.trips) {
if (!trip.start_date || !trip.end_date) continue
const start = new Date(trip.start_date + 'T00:00:00')
const end = new Date(trip.end_date + 'T00:00:00')
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
dates.add(d.toISOString().split('T')[0])
}
}
return dates
}, [current?.trips])
return {
id, navigate, toast, t, locale,
current, loading,
canEditEntries, canEditJourney, myRole,
view, setView, activeEntryId, setActiveEntryId, feedRef,
viewingEntry, setViewingEntry, editingEntry, setEditingEntry,
lightbox, setLightbox, deleteTarget, setDeleteTarget,
showInvite, setShowInvite, showAddTrip, setShowAddTrip,
unlinkTrip, setUnlinkTrip, showSettings, setShowSettings,
hideSkeletons, setHideSkeletons,
mapRef, fullMapRef, activeLocationId, handleMarkerClick, handleLocationClick,
mapEntries, sidebarMapItems, tripDates, isMobile,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
}
}
@@ -0,0 +1,52 @@
/**
* Shared types + pure helpers for the public (read-only) journey share page.
* No React, no side effects safe to import from both the data hook and the
* presentational page.
*/
export interface PublicEntry {
id: number
title?: string | null
story?: string | null
entry_date: string
entry_time?: string | null
location_name?: string | null
location_lat?: number | null
location_lng?: number | null
mood?: string | null
weather?: string | null
pros_cons?: { pros: string[]; cons: string[] } | null
photos: PublicPhoto[]
}
export interface PublicPhoto {
id: number
entry_id: number
photo_id: number
provider?: string
asset_id?: string | null
owner_id?: number | null
file_path?: string | null
caption?: string | null
}
export interface PublicGalleryPhoto {
id: number
journey_id: number
photo_id: number
provider?: string
asset_id?: string | null
owner_id?: number | null
file_path?: string | null
caption?: string | null
}
export function groupByDate(entries: PublicEntry[]): Map<string, PublicEntry[]> {
const groups = new Map<string, PublicEntry[]>()
for (const e of entries) {
const d = e.entry_date
if (!groups.has(d)) groups.set(d, [])
groups.get(d)!.push(e)
}
return groups
}
@@ -0,0 +1,108 @@
import { useEffect, useState, useMemo, useRef, useCallback } from 'react'
import { useParams } from 'react-router-dom'
import { journeyApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
import type { JourneyMapHandle } from '../../components/Journey/JourneyMap'
import { useIsMobile } from '../../hooks/useIsMobile'
import { DAY_COLORS } from '../../components/Journey/dayColors'
import { groupByDate, type PublicEntry, type PublicGalleryPhoto } from './journeyPublicModel'
/**
* Public-journey (read-only share) data hook owns the token fetch, the
* loading/error state, the view state (timeline/gallery/map, lightbox, language
* picker, active + viewing entry) and all the timeline/map derivations.
* JourneyPublicPage stays a wiring container: it keeps the presentational
* helpers (photoUrl, formatDate, mood/weather config) and the render functions
* next to the JSX, and computes the t()-dependent `availableViews` itself.
* Behaviour is identical to the previous in-component logic.
*/
export function useJourneyPublic() {
const { token } = useParams()
const [data, setData] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const isMobile = useIsMobile()
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
const [lightbox, setLightbox] = useState<{ photos: { id: string; src: string; caption?: string | null }[]; index: number } | null>(null)
const [showLangPicker, setShowLangPicker] = useState(false)
const locale = useSettingsStore(s => s.settings.language) || 'en'
const mapRef = useRef<JourneyMapHandle>(null)
const [activeEntryId, setActiveEntryId] = useState<string | null>(null)
const [viewingEntry, setViewingEntry] = useState<PublicEntry | null>(null)
const handleMarkerClick = useCallback((entryId: string) => {
setActiveEntryId(entryId)
mapRef.current?.highlightMarker(entryId)
document.querySelector(`[data-entry-id="${entryId}"]`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, [])
useEffect(() => {
if (!token) return
journeyApi.getPublicJourney(token)
.then(d => setData(d))
.catch(() => setError(true))
.finally(() => setLoading(false))
}, [token])
const entries = (data?.entries || []) as PublicEntry[]
const gallery = (data?.gallery || []) as PublicGalleryPhoto[]
const perms = data?.permissions || {}
const journey = data?.journey || {}
const stats = data?.stats || {}
const timelineEntries = useMemo(() => entries, [entries])
const groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries])
const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries])
const mapEntries = useMemo(
() => timelineEntries.filter(e => e.location_lat && e.location_lng),
[timelineEntries],
)
const allPhotos = gallery
// Map entries with day color/label for colored markers.
// dayIdx is derived from sortedDates (ALL timeline dates) so marker colors
// stay in sync with the timeline day headers even when some days have no locations.
const sidebarMapItems = useMemo(() => {
const counters = new Map<string, number>()
return mapEntries.map(e => {
const dayIdx = sortedDates.indexOf(e.entry_date)
const dayLabel = (counters.get(e.entry_date) ?? 0) + 1
counters.set(e.entry_date, dayLabel)
return {
id: String(e.id),
lat: e.location_lat!,
lng: e.location_lng!,
title: e.title || '',
mood: e.mood,
created_at: e.entry_date,
entry_date: e.entry_date,
dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length],
dayLabel,
}
})
}, [mapEntries, sortedDates])
// Two-column desktop layout: timeline feed left + sticky map right
const desktopTwoColumn = !isMobile && perms.share_timeline && perms.share_map
// Set default view based on permissions
useEffect(() => {
if (!perms.share_timeline && perms.share_gallery) setView('gallery')
else if (!perms.share_timeline && !perms.share_gallery && perms.share_map) setView('map')
}, [perms])
// When switching to desktop two-column, 'map' standalone tab no longer exists
useEffect(() => {
if (desktopTwoColumn && view === 'map') setView('timeline')
}, [desktopTwoColumn, view])
return {
token, data, loading, error, isMobile, locale,
view, setView, lightbox, setLightbox, showLangPicker, setShowLangPicker,
mapRef, activeEntryId, setActiveEntryId, viewingEntry, setViewingEntry, handleMarkerClick,
perms, journey, stats,
timelineEntries, groupedEntries, sortedDates, sidebarMapItems, allPhotos,
desktopTwoColumn,
}
}
+275
View File
@@ -0,0 +1,275 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore, hasStoredLanguage } from '../../store/settingsStore'
import { useTranslation, detectBrowserLanguage } from '../../i18n'
import { authApi, configApi } from '../../api/client'
import { getApiErrorMessage } from '../../types'
interface AppConfig {
has_users: boolean
allow_registration: boolean
setup_complete: boolean
demo_mode: boolean
oidc_configured: boolean
oidc_display_name?: string
oidc_only_mode: boolean
password_login: boolean
password_registration: boolean
oidc_login: boolean
oidc_registration: boolean
env_override_oidc_only: boolean
}
/**
* Login data hook owns the whole auth surface: login/register/demo, the MFA
* step-up, the must-change-password step, the OIDC code exchange + error
* handling, the app-config probe (with cache fallback) and the language
* detection chain. LoginPage is a pure wiring container that renders what this
* returns. Behaviour is identical to the previous in-component logic.
*/
export function useLogin() {
const { t } = useTranslation()
const [mode, setMode] = useState<'login' | 'register'>('login')
const [username, setUsername] = useState<string>('')
const [email, setEmail] = useState<string>('')
const [password, setPassword] = useState<string>('')
const [showPassword, setShowPassword] = useState<boolean>(false)
const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<string>('')
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
const [inviteToken, setInviteToken] = useState<string>('')
const [inviteValid, setInviteValid] = useState<boolean>(false)
const exchangeInitiated = useRef(false)
const [langDropdownOpen, setLangDropdownOpen] = useState<boolean>(false)
const [showTakeoff, setShowTakeoff] = useState<boolean>(false)
const [mfaStep, setMfaStep] = useState(false)
const [mfaToken, setMfaToken] = useState('')
const [mfaCode, setMfaCode] = useState('')
const [passwordChangeStep, setPasswordChangeStep] = useState(false)
const [savedLoginPassword, setSavedLoginPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const { login, register, demoLogin, completeMfaLogin, loadUser } = useAuthStore()
const { setLanguageLocal, setLanguageTransient } = useSettingsStore()
const navigate = useNavigate()
const location = useLocation()
const noRedirect = !!(location.state as { noRedirect?: boolean } | null)?.noRedirect
const redirectTarget = useMemo(() => {
const params = new URLSearchParams(window.location.search)
const redirect = params.get('redirect')
// Only allow relative paths starting with / to prevent open redirect attacks
if (redirect && redirect.startsWith('/') && !redirect.startsWith('//') && !redirect.startsWith('/\\')) {
return redirect
}
return '/dashboard'
}, [])
useEffect(() => {
if (redirectTarget !== '/dashboard') {
sessionStorage.setItem('oidc_redirect', redirectTarget)
}
}, [redirectTarget])
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const invite = params.get('invite')
const oidcCode = params.get('oidc_code')
const oidcError = params.get('oidc_error')
if (invite) {
setInviteToken(invite)
setMode('register')
authApi.validateInvite(invite).then(() => {
setInviteValid(true)
}).catch(() => {
setError(t('login.invalidInviteLink'))
})
window.history.replaceState({}, '', window.location.pathname)
}
if (oidcCode) {
if (exchangeInitiated.current) return
exchangeInitiated.current = true
setIsLoading(true)
fetch('/api/auth/oidc/exchange?code=' + encodeURIComponent(oidcCode), { credentials: 'include' })
.then(r => r.json())
.then(async data => {
window.history.replaceState({}, '', '/login')
if (data.token) {
await loadUser()
const savedRedirect = sessionStorage.getItem('oidc_redirect') || '/dashboard'
sessionStorage.removeItem('oidc_redirect')
navigate(savedRedirect, { replace: true })
} else {
setError(data.error || t('login.oidcFailed'))
}
})
.catch(() => {
window.history.replaceState({}, '', '/login')
setError(t('login.oidcFailed'))
})
.finally(() => setIsLoading(false))
return
}
if (oidcError) {
const errorMessages: Record<string, string> = {
registration_disabled: t('login.oidc.registrationDisabled'),
no_email: t('login.oidc.noEmail'),
token_failed: t('login.oidc.tokenFailed'),
invalid_state: t('login.oidc.invalidState'),
}
setError(errorMessages[oidcError] || oidcError)
sessionStorage.removeItem('oidc_redirect')
window.history.replaceState({}, '', '/login')
return
}
const CONFIG_CACHE_KEY = 'trek_app_config_cache'
authApi.getAppConfig?.()
.then((config: AppConfig) => {
try { localStorage.setItem(CONFIG_CACHE_KEY, JSON.stringify(config)) } catch { /* ignore quota errors */ }
return { config, fromCache: false }
})
.catch(() => {
try {
const raw = localStorage.getItem(CONFIG_CACHE_KEY)
return raw ? { config: JSON.parse(raw) as AppConfig, fromCache: true } : { config: null as AppConfig | null, fromCache: false }
} catch { return { config: null as AppConfig | null, fromCache: false } }
})
.then(({ config, fromCache }) => {
if (config) {
setAppConfig(config)
if (!config.has_users) setMode('register')
// Skip auto-redirect when config is from cache — network is unreliable
// and auto-redirecting to the IdP could loop if the proxy changed.
if (!fromCache && !config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) {
window.location.href = '/api/auth/oidc/login'
}
}
})
}, [navigate, t, noRedirect])
// Language detection chain (runs once on mount, only if user has no saved preference):
// 1. localStorage → already in store initial state, skip
// 2. Browser/OS language (navigator.languages)
// 3. Server default (DEFAULT_LANGUAGE env var)
// 4. 'en' → hardcoded fallback already in store
useEffect(() => {
if (hasStoredLanguage()) return
const detected = detectBrowserLanguage()
if (detected) {
setLanguageTransient(detected)
return
}
configApi.getPublicConfig()
.then(({ defaultLanguage }) => { if (defaultLanguage) setLanguageTransient(defaultLanguage) })
.catch((err) => console.warn('Failed to fetch default language config:', err))
}, [setLanguageTransient])
useEffect(() => {
if (!langDropdownOpen) return
const close = () => setLangDropdownOpen(false)
document.addEventListener('click', close)
return () => document.removeEventListener('click', close)
}, [langDropdownOpen])
const handleDemoLogin = async (): Promise<void> => {
setError('')
setIsLoading(true)
try {
await demoLogin()
setShowTakeoff(true)
setTimeout(() => navigate(redirectTarget), 2600)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('login.demoFailed'))
} finally {
setIsLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault()
setError('')
setIsLoading(true)
try {
if (passwordChangeStep) {
if (!newPassword) { setError(t('settings.passwordRequired')); setIsLoading(false); return }
if (newPassword.length < 8) { setError(t('settings.passwordTooShort')); setIsLoading(false); return }
if (newPassword !== confirmPassword) { setError(t('settings.passwordMismatch')); setIsLoading(false); return }
await authApi.changePassword({ current_password: savedLoginPassword, new_password: newPassword })
await loadUser({ silent: true })
setShowTakeoff(true)
setTimeout(() => navigate(redirectTarget), 2600)
return
}
if (mode === 'login' && mfaStep) {
if (!mfaCode.trim()) {
setError(t('login.mfaCodeRequired'))
setIsLoading(false)
return
}
const mfaResult = await completeMfaLogin(mfaToken, mfaCode)
if ('user' in mfaResult && mfaResult.user?.must_change_password) {
setSavedLoginPassword(password)
setPasswordChangeStep(true)
setIsLoading(false)
return
}
setShowTakeoff(true)
setTimeout(() => navigate(redirectTarget), 2600)
return
}
if (mode === 'register') {
if (!username.trim()) { setError(t('login.usernameRequired')); setIsLoading(false); return }
if (password.length < 8) { setError(t('login.passwordMinLength')); setIsLoading(false); return }
await register(username, email, password, inviteToken || undefined)
} else {
const result = await login(email, password)
if ('mfa_required' in result && result.mfa_required && 'mfa_token' in result) {
setMfaToken(result.mfa_token)
setMfaStep(true)
setMfaCode('')
setIsLoading(false)
return
}
if ('user' in result && result.user?.must_change_password) {
setSavedLoginPassword(password)
setPasswordChangeStep(true)
setIsLoading(false)
return
}
}
setShowTakeoff(true)
setTimeout(() => navigate(redirectTarget), 2600)
} catch (err: unknown) {
setError(getApiErrorMessage(err, t('login.error')))
setIsLoading(false)
}
}
const showRegisterOption = (appConfig?.password_registration || !appConfig?.has_users || inviteValid) && (appConfig?.setup_complete !== false || !appConfig?.has_users)
// In OIDC-only mode, show a minimal page that redirects directly to the IdP
const oidcOnly = !appConfig?.password_login && appConfig?.oidc_login && appConfig?.oidc_configured
return {
navigate,
mode, setMode,
username, setUsername, email, setEmail, password, setPassword, showPassword, setShowPassword,
isLoading, error, setError, appConfig, inviteToken,
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword,
noRedirect, showRegisterOption, oidcOnly,
handleDemoLogin, handleSubmit,
}
}
@@ -0,0 +1,156 @@
import { useEffect, useMemo, useState } from 'react'
import { useAuthStore } from '../../store/authStore'
import { oauthApi } from '../../api/client'
import { SCOPE_GROUPS } from '../../api/oauthScopes'
import { useTranslation } from '../../i18n'
interface ValidateResult {
valid: boolean
error?: string
error_description?: string
client?: { name: string; allowed_scopes: string[] }
scopes?: string[]
consentRequired?: boolean
loginRequired?: boolean
scopeSelectable?: boolean
}
type PageState = 'loading' | 'login_required' | 'consent' | 'auto_approving' | 'error' | 'done'
/**
* OAuth authorize/consent screen logic owns the validateconsent state machine,
* the requested-scope selection and the login/redirect plumbing. The page reads
* the query string once here so the controller stays a pure renderer.
* Behaviour is identical to the previous in-component logic.
*/
export function useOAuthAuthorize() {
const { t } = useTranslation()
const { isAuthenticated, isLoading: authLoading, loadUser } = useAuthStore()
const [pageState, setPageState] = useState<PageState>('loading')
const [validation, setValidation] = useState<ValidateResult | null>(null)
const [submitting, setSubmitting] = useState(false)
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const [selectedScopes, setSelectedScopes] = useState<string[]>([])
const params = new URLSearchParams(window.location.search)
const clientId = params.get('client_id') || ''
const redirectUri = params.get('redirect_uri') || ''
const scope = params.get('scope') || ''
const state = params.get('state') || ''
const codeChallenge = params.get('code_challenge') || ''
const ccMethod = params.get('code_challenge_method') || ''
const resource = params.get('resource') || undefined
// Load auth state once, then validate
useEffect(() => {
loadUser({ silent: true }).catch(() => {})
}, [loadUser])
useEffect(() => {
if (authLoading) return
validateRequest()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [authLoading, isAuthenticated])
async function validateRequest() {
setPageState('loading')
try {
const result = await oauthApi.validate({
client_id: clientId,
redirect_uri: redirectUri,
scope,
state,
code_challenge: codeChallenge,
code_challenge_method: ccMethod,
response_type: 'code',
resource,
})
setValidation(result)
if (!result.valid) {
setPageState('error')
setErrorMsg(result.error_description || result.error || 'Invalid authorization request')
return
}
if (result.loginRequired) {
setPageState('login_required')
return
}
if (!result.consentRequired) {
// Consent already on record — auto-approve silently with the full validated scope
setPageState('auto_approving')
await submitConsent(true, result.scopes ?? [])
return
}
// Pre-select all scopes the client is requesting — user can deselect
setSelectedScopes(result.scopes ?? [])
setPageState('consent')
} catch (err: unknown) {
setPageState('error')
setErrorMsg('Failed to validate authorization request. Please try again.')
}
}
async function submitConsent(approved: boolean, scopes: string[] = selectedScopes) {
setSubmitting(true)
try {
const result = await oauthApi.authorize({
client_id: clientId,
redirect_uri: redirectUri,
// When approving, send only the scopes the user selected; deny uses original scope
scope: approved ? scopes.join(' ') : scope,
state,
code_challenge: codeChallenge,
code_challenge_method: ccMethod,
approved,
resource,
})
setPageState('done')
window.location.href = result.redirect
} catch {
setPageState('error')
setErrorMsg('Authorization failed. Please try again.')
setSubmitting(false)
}
}
function toggleScope(s: string) {
setSelectedScopes(prev =>
prev.includes(s) ? prev.filter(x => x !== s) : [...prev, s]
)
}
function toggleGroup(groupScopes: string[], allSelected: boolean) {
setSelectedScopes(prev =>
allSelected
? prev.filter(s => !groupScopes.includes(s))
: [...new Set([...prev, ...groupScopes])]
)
}
function handleLoginRedirect() {
const next = '/oauth/consent?' + params.toString() + window.location.hash
window.location.href = '/login?redirect=' + encodeURIComponent(next)
}
// Group requested scopes by their translated group name
const scopesByGroup = useMemo(() => {
const requested = validation?.scopes || []
const groups: Record<string, string[]> = {}
for (const s of requested) {
const keys = SCOPE_GROUPS[s]
const group = keys ? t(keys.groupKey) : 'Other'
if (!groups[group]) groups[group] = []
groups[group].push(s)
}
return groups
}, [validation, t])
return {
pageState, validation, submitting, errorMsg, selectedScopes, clientId,
scopesByGroup, submitConsent, toggleScope, toggleGroup, handleLoginRedirect,
}
}
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useTripStore } from '../../store/tripStore'
import { tripsApi, daysApi, placesApi } from '../../api/client'
import type { Trip, Day, Place, Photo } from '../../types'
/**
* Photos page data hook owns the trip/days/places load, the photo sync from
* the trip store and the upload/delete/update handlers. PhotosPage is a pure
* wiring container. Behaviour is identical to the previous in-component logic.
*/
export function usePhotos() {
const { id: tripId } = useParams<{ id: string }>()
const navigate = useNavigate()
const tripStore = useTripStore()
const [trip, setTrip] = useState<Trip | null>(null)
const [days, setDays] = useState<Day[]>([])
const [places, setPlaces] = useState<Place[]>([])
const [photos, setPhotos] = useState<Photo[]>([])
const [isLoading, setIsLoading] = useState<boolean>(true)
useEffect(() => {
loadData()
}, [tripId])
const loadData = async (): Promise<void> => {
setIsLoading(true)
try {
const [tripData, daysData, placesData] = await Promise.all([
tripsApi.get(tripId),
daysApi.list(tripId),
placesApi.list(tripId),
])
setTrip(tripData.trip)
setDays(daysData.days)
setPlaces(placesData.places)
// Load photos
await tripStore.loadPhotos(tripId)
} catch (err: unknown) {
navigate('/dashboard')
} finally {
setIsLoading(false)
}
}
// Sync photos from store
useEffect(() => {
setPhotos(tripStore.photos)
}, [tripStore.photos])
const handleUpload = async (formData: FormData): Promise<void> => {
await tripStore.addPhoto(tripId, formData)
}
const handleDelete = async (photoId: number): Promise<void> => {
await tripStore.deletePhoto(tripId, photoId)
}
const handleUpdate = async (photoId: number, data: Record<string, string | number | null>): Promise<void> => {
await tripStore.updatePhoto(tripId, photoId, data)
}
return { tripId, navigate, trip, days, places, photos, isLoading, handleUpload, handleDelete, handleUpdate }
}
+54
View File
@@ -0,0 +1,54 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
/**
* Register data hook owns the form state, the client-side validation and the
* register redirect flow. RegisterPage is a pure wiring container. Behaviour
* is identical to the previous in-component logic.
*/
export function useRegister() {
const { t } = useTranslation()
const { register } = useAuthStore()
const navigate = useNavigate()
const [username, setUsername] = useState<string>('')
const [email, setEmail] = useState<string>('')
const [password, setPassword] = useState<string>('')
const [confirmPassword, setConfirmPassword] = useState<string>('')
const [showPassword, setShowPassword] = useState<boolean>(false)
const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<string>('')
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault()
setError('')
if (password !== confirmPassword) {
setError(t('register.passwordMismatch'))
return
}
if (password.length < 8) {
setError(t('register.passwordTooShort'))
return
}
setIsLoading(true)
try {
await register(username, email, password)
navigate('/dashboard')
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('register.failed'))
} finally {
setIsLoading(false)
}
}
return {
username, setUsername, email, setEmail, password, setPassword,
confirmPassword, setConfirmPassword, showPassword, setShowPassword,
isLoading, error, handleSubmit,
}
}
@@ -0,0 +1,66 @@
import { useState, useEffect, type FormEvent } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { authApi } from '../../api/client'
import { getApiErrorMessage } from '../../types'
import { useTranslation } from '../../i18n'
/**
* Reset-password data hook owns the token lookup, the form state, the
* client-side validation and the submit (incl. the MFA step-up branch).
* ResetPasswordPage is a pure wiring container. Behaviour is identical to the
* previous in-component logic.
*/
export function useResetPassword() {
const { t } = useTranslation()
const navigate = useNavigate()
const [params] = useSearchParams()
const token = params.get('token') || ''
const [pw, setPw] = useState('')
const [pw2, setPw2] = useState('')
const [showPw, setShowPw] = useState(false)
const [mfaCode, setMfaCode] = useState('')
const [mfaRequired, setMfaRequired] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
if (!token) setError(t('login.resetPasswordInvalidLink'))
}, [token, t])
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (isLoading) return
setError('')
if (!token) return
if (pw.length < 8) { setError(t('login.passwordMinLength')); return }
if (pw !== pw2) { setError(t('login.passwordsDontMatch')); return }
setIsLoading(true)
try {
const res = await authApi.resetPassword({
token,
new_password: pw,
...(mfaRequired && mfaCode ? { mfa_code: mfaCode.trim() } : {}),
})
if (res.mfa_required) {
setMfaRequired(true)
setIsLoading(false)
return
}
if (res.success) {
setSuccess(true)
}
} catch (err) {
setError(getApiErrorMessage(err, t('login.resetPasswordFailed')))
}
setIsLoading(false)
}
return {
navigate, token,
pw, setPw, pw2, setPw2, showPw, setShowPw,
mfaCode, setMfaCode, mfaRequired, error, success, isLoading,
handleSubmit,
}
}
+37
View File
@@ -0,0 +1,37 @@
import { useState, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { authApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore'
/**
* Settings page logic loads addons + the app version, tracks the active tab
* and the integrations-enabled gate, and auto-switches to the account tab when
* the URL signals MFA is required. SettingsPage stays a wiring container that
* builds the (t-dependent) tab list and renders the tab bodies.
* Behaviour is identical to the previous in-component logic.
*/
export function useSettings() {
const [searchParams] = useSearchParams()
const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
const memoriesEnabled = addonEnabled('memories')
const mcpEnabled = addonEnabled('mcp')
const hasIntegrations = memoriesEnabled || mcpEnabled
const [appVersion, setAppVersion] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState('display')
useEffect(() => {
loadAddons()
authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {})
}, [])
// Auto-switch to account tab when MFA is required
useEffect(() => {
if (searchParams.get('mfa') === 'required') {
setActiveTab('account')
}
}, [searchParams])
return { hasIntegrations, appVersion, activeTab, setActiveTab }
}
@@ -0,0 +1,28 @@
import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { shareApi } from '../../api/client'
/**
* Shared-trip (public) data hook owns the token lookup, the read-only share
* fetch and the view state (selected day, active tab, language picker).
* SharedTripPage is a pure wiring container; the post-load derivations
* (sortedDays, map places, ) stay in the page next to the JSX that uses them.
* Behaviour is identical to the previous in-component logic.
*/
export function useSharedTrip() {
const { token } = useParams<{ token: string }>()
// The shared payload is an open-ended snapshot (trip, days, assignments, …),
// matched 1:1 from the public share endpoint — kept loosely typed as before.
const [data, setData] = useState<any>(null)
const [error, setError] = useState(false)
const [selectedDay, setSelectedDay] = useState<number | null>(null)
const [activeTab, setActiveTab] = useState('plan')
const [showLangPicker, setShowLangPicker] = useState(false)
useEffect(() => {
if (!token) return
shareApi.getSharedTrip(token).then(setData).catch(() => setError(true))
}, [token])
return { data, error, selectedDay, setSelectedDay, activeTab, setActiveTab, showLangPicker, setShowLangPicker }
}
@@ -0,0 +1,641 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore'
import { getCached, fetchPhoto } from '../../services/photoService'
import { useToast } from '../../components/shared/Toast'
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi } from '../../api/client'
import { accommodationRepo } from '../../repo/accommodationRepo'
import { offlineDb } from '../../db/offlineDb'
import { useAuthStore } from '../../store/authStore'
import { useResizablePanels } from '../../hooks/useResizablePanels'
import { useTripWebSocket } from '../../hooks/useTripWebSocket'
import { useRouteCalculation } from '../../hooks/useRouteCalculation'
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
/**
* Trip planner page logic the big one. Owns the trip store wiring, addon
* gating, accommodations/members loading, the tab + resizable-panel + selection
* state, every place/assignment/reservation/transport CRUD handler (with undo),
* the map filters/derivations and the splash gate. TripPlannerPage stays a
* wiring container that lays out the day/map/places panes and modals.
* Behaviour is identical to the previous in-component logic.
*/
export function useTripPlanner() {
const { id: tripId } = useParams<{ id: string }>()
const navigate = useNavigate()
const toast = useToast()
const { t, language } = useTranslation()
const { settings } = useSettingsStore()
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
const trip = useTripStore(s => s.trip)
const days = useTripStore(s => s.days)
const places = useTripStore(s => s.places)
const assignments = useTripStore(s => s.assignments)
const packingItems = useTripStore(s => s.packingItems)
const todoItems = useTripStore(s => s.todoItems)
const categories = useTripStore(s => s.categories)
const reservations = useTripStore(s => s.reservations)
const budgetItems = useTripStore(s => s.budgetItems)
const files = useTripStore(s => s.files)
const selectedDayId = useTripStore(s => s.selectedDayId)
const isLoading = useTripStore(s => s.isLoading)
// Actions — stable references, don't cause re-renders
const tripActions = useRef(useTripStore.getState()).current
const can = useCanDo()
const canUploadFiles = can('file_upload', trip)
const { pushUndo, undo, canUndo, lastActionLabel } = usePlannerHistory()
const handleUndo = useCallback(async () => {
const label = lastActionLabel
await undo()
toast.info(t('undo.done', { action: label ?? '' }))
}, [undo, lastActionLabel, toast])
const [enabledAddons, setEnabledAddons] = useState<Record<string, boolean>>({ packing: true, budget: true, documents: true, collab: false })
const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, polls: true, whatsnext: true })
const [tripAccommodations, setTripAccommodations] = useState<Accommodation[]>([])
const [allowedFileTypes, setAllowedFileTypes] = useState<string | null>(null)
const [tripMembers, setTripMembers] = useState<TripMember[]>([])
const loadAccommodations = useCallback(() => {
if (tripId) {
accommodationRepo.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
tripActions.loadReservations(tripId)
}
}, [tripId])
useEffect(() => {
addonsApi.enabled().then(data => {
const map = {}
data.addons.forEach(a => { map[a.id] = true })
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab })
if (data.collabFeatures) setCollabFeatures(data.collabFeatures)
}).catch(() => {})
authApi.getAppConfig().then(config => {
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
}).catch(() => {})
}, [])
const TRANSPORT_TYPES = new Set(['flight', 'train', 'car', 'cruise', 'bus'])
const TRIP_TABS = [
{ id: 'plan', label: t('trip.tabs.plan'), icon: Map },
{ id: 'transports', label: t('trip.tabs.transports'), icon: Train },
{ id: 'buchungen', label: t('trip.tabs.reservations'), shortLabel: t('trip.tabs.reservationsShort'), icon: Ticket },
...(enabledAddons.packing ? [{ id: 'listen', label: t('trip.tabs.lists'), shortLabel: t('trip.tabs.listsShort'), icon: PackageCheck }] : []),
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget'), icon: Wallet }] : []),
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files'), icon: FolderOpen }] : []),
...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name'), icon: Users }] : []),
]
const [activeTab, setActiveTab] = useState<string>(() => {
const saved = sessionStorage.getItem(`trip-tab-${tripId}`)
return saved || 'plan'
})
useEffect(() => {
const validTabIds = TRIP_TABS.map(t => t.id)
if (!validTabIds.includes(activeTab)) {
setActiveTab('plan')
sessionStorage.setItem(`trip-tab-${tripId}`, 'plan')
}
}, [enabledAddons])
const handleTabChange = (tabId: string): void => {
setActiveTab(tabId)
sessionStorage.setItem(`trip-tab-${tripId}`, tabId)
if (tabId === 'finanzplan') tripActions.loadBudgetItems?.(tripId)
if (tabId === 'dateien' && (!files || files.length === 0)) tripActions.loadFiles?.(tripId)
}
const { leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight } = useResizablePanels()
const { selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment } = usePlaceSelection()
const [showDayDetail, setShowDayDetail] = useState<Day | null>(null)
const [dayDetailCollapsed, setDayDetailCollapsed] = useState(false)
const [showPlaceForm, setShowPlaceForm] = useState<boolean>(false)
const [editingPlace, setEditingPlace] = useState<Place | null>(null)
const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string } | null>(null)
const [editingAssignmentId, setEditingAssignmentId] = useState<number | null>(null)
const [searchParams, setSearchParams] = useSearchParams()
// The bottom-nav "+" opens the new-place form via ?create=place.
useEffect(() => {
if (searchParams.get('create') === 'place') {
setEditingPlace(null); setEditingAssignmentId(null); setShowPlaceForm(true)
setSearchParams(p => { p.delete('create'); return p }, { replace: true })
}
}, [searchParams])
const [showTripForm, setShowTripForm] = useState<boolean>(false)
const [showMembersModal, setShowMembersModal] = useState<boolean>(false)
const [showReservationModal, setShowReservationModal] = useState<boolean>(false)
const [editingReservation, setEditingReservation] = useState<Reservation | null>(null)
const [bookingForAssignmentId, setBookingForAssignmentId] = useState<number | null>(null)
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
const [transportModalDayId, setTransportModalDayId] = useState<number | null>(null)
// Manual route planning: off by default, toggled from the day-plan footer. Mode
// (driving/walking) is per-session and selects which travel time the connectors show.
const [routeShown, setRouteShown] = useState(false)
const [routeProfile, setRouteProfile] = useState<'driving' | 'walking'>('driving')
const [fitKey, setFitKey] = useState<number>(0)
const initialFitTripId = useRef<number | null>(null)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
const mobilePlanScrollTopRef = useRef<number>(0)
const mobilePlacesScrollTopRef = useRef<number>(0)
const [deletePlaceId, setDeletePlaceId] = useState<number | null>(null)
const [deletePlaceIds, setDeletePlaceIds] = useState<number[] | null>(null)
useEffect(() => {
if (!trip) return
if (initialFitTripId.current === trip.id) return
const hasGeoPlaces = places.some(p => p.lat != null && p.lng != null)
if (!hasGeoPlaces) return
initialFitTripId.current = trip.id
setFitKey(k => k + 1)
}, [trip, places])
const connectionsStorageKey = tripId ? `trek:visible-connections:${tripId}` : null
const [visibleConnections, setVisibleConnections] = useState<number[]>(() => {
if (typeof window === 'undefined' || !connectionsStorageKey) return []
try {
const stored = window.localStorage.getItem(connectionsStorageKey)
return stored ? JSON.parse(stored) as number[] : []
} catch { return [] }
})
useEffect(() => {
if (typeof window === 'undefined' || !connectionsStorageKey) return
window.localStorage.setItem(connectionsStorageKey, JSON.stringify(visibleConnections))
}, [connectionsStorageKey, visibleConnections])
const toggleConnection = useCallback((id: number) => {
setVisibleConnections(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
}, [])
const [mapTransportDetail, setMapTransportDetail] = useState<Reservation | null>(null)
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768)
useEffect(() => {
const mq = window.matchMedia('(max-width: 767px)')
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
// Start photo fetches during splash screen so images are ready when map mounts
useEffect(() => {
if (isLoading || !places || places.length === 0 || !placesPhotosEnabled) return
for (const p of places) {
if (p.image_url) continue
const cacheKey = p.google_place_id || p.osm_id || `${p.lat},${p.lng}`
if (!cacheKey || getCached(cacheKey)) continue
const photoId = p.google_place_id || p.osm_id
if (photoId || (p.lat && p.lng)) {
fetchPhoto(cacheKey, photoId || `coords:${p.lat}:${p.lng}`, p.lat, p.lng, p.name)
}
}
}, [isLoading, places])
// Load trip + files (needed for place inspector file section)
useEffect(() => {
if (tripId) {
tripActions.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
tripActions.loadFiles(tripId)
loadAccommodations()
if (!navigator.onLine) {
offlineDb.tripMembers.where('tripId').equals(Number(tripId)).toArray()
.then(rows => setTripMembers(rows))
.catch(() => {})
} else {
tripsApi.getMembers(tripId).then(d => {
const all = [d.owner, ...(d.members || [])].filter(Boolean)
setTripMembers(all)
}).catch(() => {})
}
}
}, [tripId])
useEffect(() => {
if (tripId) {
tripActions.loadReservations(tripId)
tripActions.loadBudgetItems?.(tripId)
}
}, [tripId])
useTripWebSocket(tripId)
const [mapCategoryFilter, setMapCategoryFilter] = useState<Set<string>>(new Set())
const [mapPlacesFilter, setMapPlacesFilter] = useState<string>('all')
const [expandedDayIds, setExpandedDayIds] = useState<Set<number> | null>(null)
const mapPlaces = useMemo(() => {
// Build set of place IDs assigned to collapsed days
const hiddenPlaceIds = new Set<number>()
if (expandedDayIds) {
for (const [dayId, dayAssignments] of Object.entries(assignments)) {
if (!expandedDayIds.has(Number(dayId))) {
for (const a of dayAssignments) {
if (a.place?.id) hiddenPlaceIds.add(a.place.id)
}
}
}
// Don't hide places that are also assigned to an expanded day
for (const [dayId, dayAssignments] of Object.entries(assignments)) {
if (expandedDayIds.has(Number(dayId))) {
for (const a of dayAssignments) {
hiddenPlaceIds.delete(a.place?.id)
}
}
}
}
// Build set of planned place IDs for unplanned filter
const plannedIds = mapPlacesFilter === 'unplanned'
? new Set(Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean)))
: null
return places.filter(p => {
if (!p.lat || !p.lng) return false
if (mapPlacesFilter === 'tracks' && !p.route_geometry) return false
if (mapCategoryFilter.size > 0) {
if (p.category_id == null) {
if (!mapCategoryFilter.has('uncategorized')) return false
} else if (!mapCategoryFilter.has(String(p.category_id))) return false
}
if (hiddenPlaceIds.has(p.id)) return false
if (plannedIds && plannedIds.has(p.id)) return false
return true
})
}, [places, mapCategoryFilter, mapPlacesFilter, assignments, expandedDayIds])
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile)
const handleSelectDay = useCallback((dayId, skipFit) => {
const changed = dayId !== selectedDayId
tripActions.setSelectedDay(dayId)
if (changed && !skipFit) setFitKey(k => k + 1)
setMobileSidebarOpen(null)
updateRouteForDay(dayId)
}, [updateRouteForDay, selectedDayId])
const handlePlaceClick = useCallback((placeId, assignmentId) => {
if (assignmentId) {
selectAssignment(assignmentId, placeId)
} else {
setSelectedPlaceId(placeId)
}
if (placeId) { setShowDayDetail(null); setLeftCollapsed(false); setRightCollapsed(false) }
}, [selectAssignment, setSelectedPlaceId])
const handleMarkerClick = useCallback((placeId) => {
if (placeId === undefined) {
setSelectedPlaceId(null)
return
}
// Find every assignment for this place (same place can sit on several
// days / be planned twice in one day). Cycle through them on repeated
// marker clicks so the sidebar highlight jumps to the next occurrence
// instead of leaving the user confused.
const allAssignments = Object.values(useTripStore.getState().assignments || {}).flat()
const matching = allAssignments.filter(a => a?.place?.id === placeId)
if (matching.length === 0) {
setSelectedPlaceId(prev => prev === placeId ? null : placeId)
} else if (matching.length === 1) {
const only = matching[0]
if (selectedAssignmentId === only.id) {
setSelectedPlaceId(null)
} else {
selectAssignment(only.id, placeId)
}
} else {
const currentIdx = matching.findIndex(a => a.id === selectedAssignmentId)
const nextIdx = currentIdx === -1 ? 0 : currentIdx + 1
if (nextIdx >= matching.length) {
// cycled past the last occurrence — clear selection so the next
// click starts fresh at occurrence 0.
setSelectedPlaceId(null)
} else {
selectAssignment(matching[nextIdx].id, placeId)
}
}
setLeftCollapsed(false); setRightCollapsed(false)
}, [selectAssignment, selectedAssignmentId, setSelectedPlaceId])
const handleMapClick = useCallback(() => {
setSelectedPlaceId(null)
}, [])
const handleMapContextMenu = useCallback(async (e) => {
if (!can('place_edit', trip)) return
e.originalEvent?.preventDefault()
const { lat, lng } = e.latlng
setPrefillCoords({ lat, lng })
setEditingPlace(null)
setEditingAssignmentId(null)
setShowPlaceForm(true)
try {
const { mapsApi } = await import('../../api/client')
const data = await mapsApi.reverse(lat, lng, language)
if (data.name || data.address) {
setPrefillCoords(prev => prev ? { ...prev, name: data.name || '', address: data.address || '' } : prev)
}
} catch { /* best effort */ }
}, [language])
const handleSavePlace = useCallback(async (data) => {
const pendingFiles = data._pendingFiles
delete data._pendingFiles
if (editingPlace) {
// Always strip time fields from place update — time is per-assignment only
const { place_time, end_time, ...placeData } = data
await tripActions.updatePlace(tripId, editingPlace.id, placeData)
// If editing from assignment context, save time per-assignment
if (editingAssignmentId) {
await assignmentsApi.updateTime(tripId, editingAssignmentId, { place_time: place_time || null, end_time: end_time || null })
await tripActions.refreshDays(tripId)
}
// Upload pending files with place_id
if (pendingFiles?.length > 0) {
for (const file of pendingFiles) {
const fd = new FormData()
fd.append('file', file)
fd.append('place_id', editingPlace.id)
try { await tripActions.addFile(tripId, fd) } catch {}
}
}
toast.success(t('trip.toast.placeUpdated'))
} else {
const place = await tripActions.addPlace(tripId, data)
if (pendingFiles?.length > 0 && place?.id) {
for (const file of pendingFiles) {
const fd = new FormData()
fd.append('file', file)
fd.append('place_id', place.id)
try { await tripActions.addFile(tripId, fd) } catch {}
}
}
toast.success(t('trip.toast.placeAdded'))
if (place?.id) {
const capturedId = place.id
pushUndo(t('undo.addPlace'), async () => {
await tripActions.deletePlace(tripId, capturedId)
})
}
}
}, [editingPlace, editingAssignmentId, tripId, toast, pushUndo])
const handleDeletePlace = useCallback((placeId) => {
setDeletePlaceId(placeId)
}, [])
const confirmDeletePlace = useCallback(async () => {
if (!deletePlaceId) return
const state = useTripStore.getState()
const capturedPlace = state.places.find(p => p.id === deletePlaceId)
const capturedAssignments = Object.entries(state.assignments).flatMap(([dayId, as]) =>
as.filter(a => a.place?.id === deletePlaceId).map(a => ({ dayId: Number(dayId), orderIndex: a.order_index }))
)
try {
await tripActions.deletePlace(tripId, deletePlaceId)
if (selectedPlaceId === deletePlaceId) setSelectedPlaceId(null)
updateRouteForDay(selectedDayId)
toast.success(t('trip.toast.placeDeleted'))
if (capturedPlace) {
pushUndo(t('undo.deletePlace'), async () => {
const newPlace = await tripActions.addPlace(tripId, {
name: capturedPlace.name,
description: capturedPlace.description,
lat: capturedPlace.lat,
lng: capturedPlace.lng,
address: capturedPlace.address,
category_id: capturedPlace.category_id,
icon: capturedPlace.icon,
price: capturedPlace.price,
})
for (const { dayId, orderIndex } of capturedAssignments) {
await tripActions.assignPlaceToDay(tripId, dayId, newPlace.id, orderIndex)
}
})
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [deletePlaceId, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo])
const confirmDeletePlaces = useCallback(async (ids?: number[]) => {
const targetIds = ids ?? deletePlaceIds
if (!targetIds?.length) return
const state = useTripStore.getState()
const capturedPlaces = state.places.filter(p => targetIds.includes(p.id))
const capturedAssignments = Object.entries(state.assignments).flatMap(([dayId, as]) =>
as.filter(a => a.place?.id != null && targetIds.includes(a.place.id)).map(a => ({ dayId: Number(dayId), placeId: a.place!.id, orderIndex: a.order_index }))
)
try {
await tripActions.deletePlacesMany(tripId, targetIds)
if (selectedPlaceId != null && targetIds.includes(selectedPlaceId)) setSelectedPlaceId(null)
if (!ids) setDeletePlaceIds(null)
updateRouteForDay(selectedDayId)
toast.success(t('trip.toast.placesDeleted', { count: capturedPlaces.length }))
if (capturedPlaces.length > 0) {
pushUndo(t('undo.deletePlaces'), async () => {
for (const place of capturedPlaces) {
const newPlace = await tripActions.addPlace(tripId, {
name: place.name, description: place.description,
lat: place.lat, lng: place.lng, address: place.address,
category_id: place.category_id, icon: place.icon, price: place.price,
})
for (const a of capturedAssignments.filter(x => x.placeId === place.id)) {
await tripActions.assignPlaceToDay(tripId, a.dayId, newPlace.id, a.orderIndex)
}
}
})
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [deletePlaceIds, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo])
const handleAssignToDay = useCallback(async (placeId, dayId, position) => {
const target = dayId || selectedDayId
if (!target) { toast.error(t('trip.toast.selectDay')); return }
try {
const assignment = await tripActions.assignPlaceToDay(tripId, target, placeId, position)
toast.success(t('trip.toast.assignedToDay'))
updateRouteForDay(target)
if (assignment?.id) {
const capturedAssignmentId = assignment.id
const capturedTarget = target
pushUndo(t('undo.assignPlace'), async () => {
await tripActions.removeAssignment(tripId, capturedTarget, capturedAssignmentId)
})
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [selectedDayId, tripId, toast, updateRouteForDay, pushUndo])
const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => {
const state = useTripStore.getState()
const capturedAssignment = (state.assignments[String(dayId)] || []).find(a => a.id === assignmentId)
const capturedPlaceId = capturedAssignment?.place?.id
const capturedOrderIndex = capturedAssignment?.order_index ?? 0
try {
await tripActions.removeAssignment(tripId, dayId, assignmentId)
updateRouteForDay(dayId)
if (capturedPlaceId != null) {
const capturedDayId = dayId
const capturedPos = capturedOrderIndex
pushUndo(t('undo.removeAssignment'), async () => {
await tripActions.assignPlaceToDay(tripId, capturedDayId, capturedPlaceId, capturedPos)
})
}
}
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [tripId, toast, updateRouteForDay, pushUndo])
const handleReorder = useCallback((dayId, orderedIds) => {
const prevIds = (useTripStore.getState().assignments[String(dayId)] || [])
.slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id)
try {
tripActions.reorderAssignments(tripId, dayId, orderedIds)
.then(() => {
const capturedDayId = dayId
const capturedPrevIds = prevIds
pushUndo(t('undo.reorder'), async () => {
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds)
})
})
.catch(err => toast.error(err instanceof Error ? err.message : t('trip.toast.reorderError')))
updateRouteForDay(dayId)
}
catch { toast.error(t('trip.toast.reorderError')) }
}, [tripId, toast, pushUndo, updateRouteForDay])
const handleUpdateDayTitle = useCallback(async (dayId, title) => {
try { await tripActions.updateDayTitle(tripId, dayId, title) }
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [tripId, toast])
const handleSaveReservation = async (data) => {
try {
if (editingReservation) {
const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null })
toast.success(t('trip.toast.reservationUpdated'))
setShowReservationModal(false)
setEditingReservation(null)
if (data.type === 'hotel') {
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
}
return r
} else {
const r = await tripActions.addReservation(tripId, { ...data, day_id: selectedDayId || null })
toast.success(t('trip.toast.reservationAdded'))
setShowReservationModal(false)
// Refresh accommodations if hotel was created
if (data.type === 'hotel') {
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
}
return r
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}
const handleSaveTransport = async (data) => {
try {
if (editingTransport) {
const r = await tripActions.updateReservation(tripId, editingTransport.id, data)
toast.success(t('trip.toast.reservationUpdated'))
setShowTransportModal(false)
setEditingTransport(null)
setTransportModalDayId(null)
return r
} else {
const r = await tripActions.addReservation(tripId, data)
toast.success(t('trip.toast.reservationAdded'))
setShowTransportModal(false)
setEditingTransport(null)
setTransportModalDayId(null)
return r
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}
const handleDeleteReservation = async (id) => {
try {
await tripActions.deleteReservation(tripId, id)
toast.success(t('trip.toast.deleted'))
// Refresh accommodations in case a hotel booking was deleted
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
}
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
// Build placeId → order-number map from the selected day's assignments
const dayOrderMap = useMemo(() => {
if (!selectedDayId) return {}
const da = assignments[String(selectedDayId)] || []
const sorted = [...da].sort((a, b) => a.order_index - b.order_index)
const map = {}
sorted.forEach((a, i) => {
if (!a.place?.id) return
if (!map[a.place.id]) map[a.place.id] = []
map[a.place.id].push(i + 1)
})
return map
}, [selectedDayId, assignments])
// Places assigned to selected day (with coords) — used for map fitting
const dayPlaces = useMemo(() => {
if (!selectedDayId) return []
const da = assignments[String(selectedDayId)] || []
return da.map(a => a.place).filter(p => p?.lat && p?.lng)
}, [selectedDayId, assignments])
const mapTileUrl = settings.map_tile_url || 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
const defaultCenter = [settings.default_lat || 48.8566, settings.default_lng || 2.3522]
const defaultZoom = settings.default_zoom || 10
const fontStyle = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif" }
// Splash screen — show for initial load + a brief moment for photos to start loading
const [splashDone, setSplashDone] = useState(false)
useEffect(() => {
if (!isLoading && trip) {
const timer = setTimeout(() => setSplashDone(true), 1500)
return () => clearTimeout(timer)
}
}, [isLoading, trip])
return {
tripId, navigate, toast, t, language, settings, placesPhotosEnabled,
trip, days, places, assignments, packingItems, todoItems, categories, reservations, budgetItems, files,
selectedDayId, isLoading, tripActions, can, canUploadFiles,
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
allowedFileTypes, tripMembers, setTripMembers, loadAccommodations,
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight,
selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment,
showDayDetail, setShowDayDetail, dayDetailCollapsed, setDayDetailCollapsed,
showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace,
prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId,
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
bookingForAssignmentId, setBookingForAssignmentId,
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
transportModalDayId, setTransportModalDayId,
routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey,
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds,
visibleConnections, setVisibleConnections, toggleConnection, mapTransportDetail, setMapTransportDetail,
isMobile, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter,
expandedDayIds, setExpandedDayIds, mapPlaces,
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu,
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
handleAssignToDay, handleRemoveAssignment, handleReorder, handleUpdateDayTitle,
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
selectedPlace, dayOrderMap, dayPlaces,
mapTileUrl, defaultCenter, defaultZoom, fontStyle, splashDone,
}
}
+58
View File
@@ -0,0 +1,58 @@
import { useEffect, useState, useCallback } from 'react'
import { useVacayStore } from '../../store/vacayStore'
import { addListener, removeListener } from '../../api/websocket'
/**
* Vacay page logic pulls the vacay store, owns the page-local UI state
* (settings modal, delete-year prompt, mobile drawer), wires the WebSocket live
* sync and the per-year (re)loads, and exposes the add-prev/next-year helpers.
* VacayPage stays a wiring container around its sidebar/calendar JSX.
* Behaviour is identical to the previous in-component logic.
*/
export function useVacay() {
const { years, selectedYear, setSelectedYear, addYear, removeYear, loadAll, loadPlan, loadEntries, loadStats, loadHolidays, loading, incomingInvites, acceptInvite, declineInvite, plan } = useVacayStore()
const [showSettings, setShowSettings] = useState<boolean>(false)
const [deleteYear, setDeleteYear] = useState<number | null>(null)
const [showMobileSidebar, setShowMobileSidebar] = useState<boolean>(false)
useEffect(() => { loadAll() }, [])
// Live sync via WebSocket
const handleWsMessage = useCallback((msg: { type: string }) => {
if (msg.type === 'vacay:update' || msg.type === 'vacay:settings') {
loadPlan()
loadEntries(selectedYear)
loadStats(selectedYear)
if (msg.type === 'vacay:settings') loadAll()
}
if (msg.type === 'vacay:invite' || msg.type === 'vacay:accepted' || msg.type === 'vacay:declined' || msg.type === 'vacay:cancelled' || msg.type === 'vacay:dissolved') {
loadAll()
}
}, [selectedYear])
useEffect(() => {
addListener(handleWsMessage)
return () => removeListener(handleWsMessage)
}, [handleWsMessage])
useEffect(() => {
if (selectedYear) { loadEntries(selectedYear); loadStats(selectedYear); loadHolidays(selectedYear) }
}, [selectedYear])
const handleAddNextYear = () => {
const nextYear = years.length > 0 ? Math.max(...years) + 1 : new Date().getFullYear()
addYear(nextYear)
}
const handleAddPrevYear = () => {
const prevYear = years.length > 0 ? Math.min(...years) - 1 : new Date().getFullYear()
addYear(prevYear)
}
return {
years, selectedYear, setSelectedYear, removeYear, loading,
incomingInvites, acceptInvite, declineInvite, plan,
showSettings, setShowSettings, deleteYear, setDeleteYear,
showMobileSidebar, setShowMobileSidebar,
handleAddNextYear, handleAddPrevYear,
}
}
@@ -1,6 +1,10 @@
import { create } from 'zustand'
import { inAppNotificationsApi } from '../api/client'
// The server contract (@trek/shared `inAppListResultSchema`) deliberately keeps
// each notification row as an open record — the registry-derived shape varies by
// type. This is the client's structured view of that row; the list/unread-count
// responses themselves are now typed + DEV-validated via inAppNotificationsApi.
export interface InAppNotification {
id: number
type: 'simple' | 'boolean' | 'navigate'
+9 -26
View File
@@ -1,29 +1,11 @@
import { create } from 'zustand';
import axios from '../api/client.js';
import axios, { parseInDev } from '../api/client';
import { systemNoticeDtoSchema, type SystemNoticeDto } from '@trek/shared';
// 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;
}
// The notice contract lives in @trek/shared (single source of truth, shared
// with the server). Keep the historical name as an alias so the existing
// SystemNoticeBanner/Modal consumers don't need to change their imports.
export type SystemNoticeDTO = SystemNoticeDto;
interface SystemNoticeState {
notices: SystemNoticeDTO[];
@@ -43,8 +25,9 @@ export const useSystemNoticeStore = create<SystemNoticeState>()((set, get) => ({
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 });
const res = await axios.get('/system-notices/active');
const notices = parseInDev(systemNoticeDtoSchema.array(), res.data, 'systemNotices.fetch');
set({ notices, 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);
+13 -8
View File
@@ -2,6 +2,11 @@ import { create } from 'zustand'
import apiClient from '../api/client'
import type { AxiosResponse } from 'axios'
import type { VacayPlan, VacayUser, VacayEntry, VacayStat, HolidaysMap, HolidayInfo, VacayHolidayCalendar } from '../types'
import type {
VacaySetColorRequest, VacayInviteRequest, VacayInviteActionRequest,
VacayAddYearRequest, VacayToggleEntryRequest, VacayCompanyHolidayRequest,
VacayUpdateStatsRequest,
} from '@trek/shared'
const ax = apiClient
@@ -73,21 +78,21 @@ interface VacayApi {
const api: VacayApi = {
getPlan: () => ax.get('/addons/vacay/plan').then((r: AxiosResponse) => r.data),
updatePlan: (data) => ax.put('/addons/vacay/plan', data).then((r: AxiosResponse) => r.data),
updateColor: (color, targetUserId) => ax.put('/addons/vacay/color', { color, target_user_id: targetUserId }).then((r: AxiosResponse) => r.data),
invite: (userId) => ax.post('/addons/vacay/invite', { user_id: userId }).then((r: AxiosResponse) => r.data),
acceptInvite: (planId) => ax.post('/addons/vacay/invite/accept', { plan_id: planId }).then((r: AxiosResponse) => r.data),
declineInvite: (planId) => ax.post('/addons/vacay/invite/decline', { plan_id: planId }).then((r: AxiosResponse) => r.data),
updateColor: (color, targetUserId) => ax.put('/addons/vacay/color', { color, target_user_id: targetUserId } satisfies VacaySetColorRequest).then((r: AxiosResponse) => r.data),
invite: (userId) => ax.post('/addons/vacay/invite', { user_id: userId } satisfies VacayInviteRequest).then((r: AxiosResponse) => r.data),
acceptInvite: (planId) => ax.post('/addons/vacay/invite/accept', { plan_id: planId } satisfies VacayInviteActionRequest).then((r: AxiosResponse) => r.data),
declineInvite: (planId) => ax.post('/addons/vacay/invite/decline', { plan_id: planId } satisfies VacayInviteActionRequest).then((r: AxiosResponse) => r.data),
cancelInvite: (userId) => ax.post('/addons/vacay/invite/cancel', { user_id: userId }).then((r: AxiosResponse) => r.data),
dissolve: () => ax.post('/addons/vacay/dissolve').then((r: AxiosResponse) => r.data),
availableUsers: () => ax.get('/addons/vacay/available-users').then((r: AxiosResponse) => r.data),
getYears: () => ax.get('/addons/vacay/years').then((r: AxiosResponse) => r.data),
addYear: (year) => ax.post('/addons/vacay/years', { year }).then((r: AxiosResponse) => r.data),
addYear: (year) => ax.post('/addons/vacay/years', { year } satisfies VacayAddYearRequest).then((r: AxiosResponse) => r.data),
removeYear: (year) => ax.delete(`/addons/vacay/years/${year}`).then((r: AxiosResponse) => r.data),
getEntries: (year) => ax.get(`/addons/vacay/entries/${year}`).then((r: AxiosResponse) => r.data),
toggleEntry: (date, targetUserId) => ax.post('/addons/vacay/entries/toggle', { date, target_user_id: targetUserId }).then((r: AxiosResponse) => r.data),
toggleCompanyHoliday: (date) => ax.post('/addons/vacay/entries/company-holiday', { date }).then((r: AxiosResponse) => r.data),
toggleEntry: (date, targetUserId) => ax.post('/addons/vacay/entries/toggle', { date, target_user_id: targetUserId } satisfies VacayToggleEntryRequest).then((r: AxiosResponse) => r.data),
toggleCompanyHoliday: (date) => ax.post('/addons/vacay/entries/company-holiday', { date } satisfies VacayCompanyHolidayRequest).then((r: AxiosResponse) => r.data),
getStats: (year) => ax.get(`/addons/vacay/stats/${year}`).then((r: AxiosResponse) => r.data),
updateStats: (year, days, targetUserId) => ax.put(`/addons/vacay/stats/${year}`, { vacation_days: days, target_user_id: targetUserId }).then((r: AxiosResponse) => r.data),
updateStats: (year, days, targetUserId) => ax.put(`/addons/vacay/stats/${year}`, { vacation_days: days, target_user_id: targetUserId } satisfies VacayUpdateStatsRequest).then((r: AxiosResponse) => r.data),
getCountries: () => ax.get('/addons/vacay/holidays/countries').then((r: AxiosResponse) => r.data),
getHolidays: (year, country) => ax.get(`/addons/vacay/holidays/${year}/${country}`).then((r: AxiosResponse) => r.data),
addHolidayCalendar: (data) => ax.post('/addons/vacay/plan/holiday-calendars', data).then((r: AxiosResponse) => r.data),
-12
View File
@@ -1,12 +0,0 @@
interface ItemWithId {
id: number
[key: string]: unknown
}
export function swapItems(items: ItemWithId[], index: number, direction: 'up' | 'down'): number[] | null {
const target = direction === 'up' ? index - 1 : index + 1
if (target < 0 || target >= items.length) return null
const ids = items.map((a) => a.id)
;[ids[index], ids[target]] = [ids[target], ids[index]]
return ids
}
+1476 -3656
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -27,6 +27,11 @@
"devDependencies": {
"concurrently": "^9.2.1"
},
"comment:overrides": "Force a single React 19 across the workspace so the test renderer (@testing-library/react) and the app share one react-dom.",
"overrides": {
"react": "19.2.6",
"react-dom": "19.2.6"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-musl": "4.60.4",
"@rollup/rollup-linux-arm64-musl": "4.60.4",
+2 -142
View File
@@ -1,14 +1,10 @@
import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
import path from 'node:path';
import fs from 'node:fs';
import multer from 'multer';
import { logDebug, logWarn, logError } from './services/auditLog';
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
import { authenticate, verifyJwtAndLoadUser } from './middleware/auth';
import { applyGlobalMiddleware } from './middleware/globalMiddleware';
import { db } from './db/database';
import authRoutes from './routes/auth';
@@ -60,143 +56,7 @@ import { getMcpSafeUrl } from './services/notifications';
export function createApp(): express.Application {
const app = express();
// Trust first proxy (nginx/Docker) for correct req.ip
if (process.env.NODE_ENV?.toLowerCase() === 'production' || process.env.TRUST_PROXY) {
app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1);
}
const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
: null;
let corsOrigin: cors.CorsOptions['origin'];
if (allowedOrigins) {
corsOrigin = (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
if (!origin || allowedOrigins.includes(origin)) callback(null, true);
else callback(new Error('Not allowed by CORS'));
};
} else if (process.env.NODE_ENV?.toLowerCase() === 'production') {
corsOrigin = false;
} else {
corsOrigin = true;
}
const shouldForceHttps = process.env.FORCE_HTTPS?.toLowerCase() === 'true';
// HSTS is worth enabling any time we're serving production traffic,
// not only when FORCE_HTTPS is set. Self-hosters behind Traefik /
// Caddy / Cloudflare Tunnel typically leave FORCE_HTTPS unset (the
// proxy handles the redirect for them), and the previous "HSTS off by
// default" meant those instances never advertised HSTS at all.
//
// `includeSubDomains` stays OFF by default on purpose: an instance
// running on an apex domain would otherwise force HTTPS on every
// sibling subdomain the same operator may still be running over plain
// HTTP. Operators who want the stricter policy opt in with
// `HSTS_INCLUDE_SUBDOMAINS=true`.
const hstsActive = shouldForceHttps || process.env.NODE_ENV === 'production';
const hstsIncludeSubdomains = process.env.HSTS_INCLUDE_SUBDOMAINS === 'true';
// RFC 8414 / RFC 9728 / RFC 7591: discovery docs and DCR are world-readable/writable.
// /mcp needs open CORS so external MCP clients (ChatGPT, Claude.ai, Inspector) can call it
// with Bearer tokens from any origin. /oauth/register and /oauth/authorize need it for
// browser-based DCR/authorization preflights — the global cors({ origin: false }) would
// answer OPTIONS without Access-Control-Allow-Origin before the SDK's own cors() runs.
// All /.well-known/* paths get open CORS so clients probing openid-configuration or the
// RFC 8414 path-suffixed AS metadata form don't get CORS-blocked (they get 404 JSON instead).
app.use(
(req: Request, _res: Response, next: NextFunction) => {
if (
req.path.startsWith('/.well-known/') ||
req.path === '/oauth/register' ||
req.path === '/oauth/authorize' ||
req.path === '/oauth/userinfo' ||
req.path === '/mcp'
) {
cors({ origin: '*', credentials: false })(req, _res, next);
} else {
next();
}
},
);
app.use(cors({ origin: corsOrigin, credentials: true }));
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
imgSrc: ["'self'", "data:", "blob:", "https:"],
connectSrc: [
"'self'", "ws:", "wss:",
"https://nominatim.openstreetmap.org", "https://overpass-api.de",
"https://places.googleapis.com", "https://api.openweathermap.org",
"https://en.wikipedia.org", "https://commons.wikimedia.org",
"https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org",
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson",
"https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/",
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com"
],
workerSrc: ["'self'", "blob:"],
childSrc: ["'self'", "blob:"],
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
objectSrc: ["'none'"],
frameSrc: ["'none'"],
frameAncestors: ["'self'"],
upgradeInsecureRequests: shouldForceHttps ? [] : null
}
},
crossOriginEmbedderPolicy: false,
hsts: hstsActive ? { maxAge: 31536000, includeSubDomains: hstsIncludeSubdomains } : false,
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
if (shouldForceHttps) {
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path === '/api/health') return next();
if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next();
res.redirect(301, 'https://' + req.headers.host + req.url);
});
}
app.use(express.json({ limit: '100kb' }));
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use(enforceGlobalMfaPolicy);
// Request logging with sensitive field redaction
{
const SENSITIVE_KEYS = new Set(['password', 'new_password', 'current_password', 'token', 'jwt', 'authorization', 'cookie', 'client_secret', 'mfa_token', 'code', 'smtp_pass']);
const redact = (value: unknown): unknown => {
if (!value || typeof value !== 'object') return value;
if (Array.isArray(value)) return (value as unknown[]).map(redact);
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = SENSITIVE_KEYS.has(k.toLowerCase()) ? '[REDACTED]' : redact(v);
}
return out;
};
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path === '/api/health') return next();
const startedAt = Date.now();
res.on('finish', () => {
const ms = Date.now() - startedAt;
if (res.statusCode >= 500) {
logError(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
} else if (res.statusCode === 401 || res.statusCode === 403) {
logDebug(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
} else if (res.statusCode >= 400) {
logWarn(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
}
const q = Object.keys(req.query).length ? ` query=${JSON.stringify(redact(req.query))}` : '';
const b = req.body && Object.keys(req.body).length ? ` body=${JSON.stringify(redact(req.body))}` : '';
logDebug(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}${q}${b}`);
});
next();
});
}
applyGlobalMiddleware(app);
// Static: avatars, covers, and journey photos.
//
+7
View File
@@ -13,6 +13,13 @@ const isTest = process.env.NODE_ENV === 'test';
let dbPath: string;
if (isTest) {
dbPath = ':memory:';
} else if (process.env.TREK_DB_FILE) {
// Explicit DB file (used by the Playwright E2E harness to run against an
// isolated, throwaway database instead of the real data/travel.db). Purely
// additive — when unset the default path below is used exactly as before.
dbPath = process.env.TREK_DB_FILE;
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
} else {
const dataDir = path.join(__dirname, '../../data');
if (!fs.existsSync(dataDir)) {
+8 -4
View File
@@ -4,13 +4,13 @@ import path from 'node:path';
import fs from 'node:fs';
import http from 'node:http';
import express from 'express';
import cookieParser from 'cookie-parser';
import { NestFactory } from '@nestjs/core';
import { ExpressAdapter } from '@nestjs/platform-express';
import type { INestApplication } from '@nestjs/common';
import { createApp } from './app';
import { AppModule } from './nest/app.module';
import { getNestPrefixes, makeNestPathMatcher } from './nest/strangler';
import { applyGlobalMiddleware } from './middleware/globalMiddleware';
// Create upload and data directories on startup
const uploadsDir = path.join(__dirname, '../uploads');
@@ -115,11 +115,15 @@ async function bootstrap(): Promise<void> {
// prefixes to this instance, so the legacy app (and raw-body routes like /mcp)
// is reached separately and never passes through Nest's parser.
nestApp = await NestFactory.create(AppModule, new ExpressAdapter());
// cookie-parser so the auth guard can read the existing `trek_session` cookie.
nestApp.use(cookieParser());
const nestInstance = nestApp.getHttpAdapter().getInstance();
// Apply the SAME global request pipeline the legacy app uses (helmet/CSP, CORS,
// HSTS, forced-HTTPS, the global MFA policy, request logging + cookie-parser) so a
// migrated Nest route is protected identically to the legacy fallback. Without this
// the dispatcher forwards Nest paths straight to this instance, bypassing all of it.
// Nest does its own body parsing, so bodyParser:false avoids parsing twice.
applyGlobalMiddleware(nestInstance, { bodyParser: false });
// (TrekExceptionFilter is registered globally via APP_FILTER in AppModule.)
await nestApp.init();
const nestInstance = nestApp.getHttpAdapter().getInstance();
// Top-level dispatcher: migrated prefixes -> Nest, everything else -> legacy
// Express (unchanged). Nest never sees non-migrated paths, so its 404 handler
+9 -9
View File
@@ -46,17 +46,16 @@ export function registerMcpPrompts(server: McpServer, _userId: number, isStaticT
if (!summary) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found.' } }] };
}
const { trip, days, members, budget, packing, reservations, collabNotes } = summary;
const packingStats = packing ? { total: packing.length, packed: packing.filter((p: any) => p.checked).length } : { total: 0, packed: 0 };
const budgetTotal = budget?.reduce((sum: number, b: any) => sum + (b.total_price || 0), 0) || 0;
const { trip, days, members, budget, packing, reservations, collab_notes } = summary;
const memberList = [members?.owner, ...(members?.collaborators || [])].filter(Boolean);
const text = `Trip: ${trip?.title || 'Untitled'}${trip?.description ? `\n${trip.description}` : ''}
Dates: ${trip?.start_date || '?'} to ${trip?.end_date || '?'}
Members: ${members?.length || 0} (${members?.map((m: any) => m.name || m.email).join(', ') || 'none'})
Members: ${memberList.length} (${memberList.map((m: any) => m.name || m.email).join(', ') || 'none'})
Days: ${days?.length || 0}
Packing: ${packingStats.packed}/${packingStats.total} items packed
Budget: ${budgetTotal} ${trip?.currency || 'EUR'} total
Packing: ${packing?.checked || 0}/${packing?.total || 0} items packed
Budget: ${budget?.total || 0} ${trip?.currency || 'EUR'} total
Reservations: ${reservations?.length || 0}
Collab Notes: ${collabNotes?.length || 0}
Collab Notes: ${collab_notes?.length || 0}
${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.length || 0} places${d.title ? ` - ${d.title}` : ''}`).join('\n') || 'No days yet'}`;
return {
description: `Summary of trip "${trip?.title || tripId}"`,
@@ -118,7 +117,7 @@ ${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.l
}
const { trip, budget } = summary;
const currency = trip?.currency || 'EUR';
const byCategory = (budget || []).reduce((acc: Record<string, number>, item: any) => {
const byCategory = (budget?.items || []).reduce((acc: Record<string, number>, item: any) => {
const cat = item.category || 'Uncategorized';
acc[cat] = (acc[cat] || 0) + (item.total_price || 0);
return acc;
@@ -128,7 +127,8 @@ ${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.l
.sort(([, a], [, b]) => b - a)
.map(([cat, amount]) => `- ${cat}: ${amount} ${currency}`)
.join('\n');
const perPerson = (summary.members?.length || 1) > 0 ? (total / (summary.members?.length || 1)).toFixed(2) : total.toFixed(2);
const memberCount = Math.max(1, [summary.members?.owner, ...(summary.members?.collaborators || [])].filter(Boolean).length);
const perPerson = (total / memberCount).toFixed(2);
return {
description: `Budget overview for "${trip?.title || tripId}"`,
messages: [{ role: 'user', content: { type: 'text', text: `# Budget: ${trip?.title || 'Trip'}\n\n**Total: ${total} ${currency}** (${perPerson} ${currency} per person)\n\n${lines || 'No expenses recorded.'}` } }],
+163
View File
@@ -0,0 +1,163 @@
import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
import { logDebug, logWarn, logError } from '../services/auditLog';
import { enforceGlobalMfaPolicy } from './mfaPolicy';
/**
* The global request pipeline shared by the legacy Express app and the NestJS
* instance. Both mount the *exact same* config so a request hitting a migrated
* Nest route is protected identically to one hitting the legacy fallback
* (helmet/CSP, CORS, HSTS, forced-HTTPS, the global MFA policy and request
* logging). Keeping it in one place is what makes the strangler dispatch
* behaviourally transparent and is the prerequisite for retiring Express,
* since the Nest instance must carry the whole shell on its own.
*
* `bodyParser` is opt-out: the Nest instance does its own body parsing, so it
* passes `false` to avoid parsing the request twice.
*/
export function applyGlobalMiddleware(
app: express.Application,
opts: { bodyParser?: boolean } = {},
): void {
const { bodyParser = true } = opts;
// Trust first proxy (nginx/Docker) for correct req.ip
if (process.env.NODE_ENV?.toLowerCase() === 'production' || process.env.TRUST_PROXY) {
app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1);
}
const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
: null;
let corsOrigin: cors.CorsOptions['origin'];
if (allowedOrigins) {
corsOrigin = (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
if (!origin || allowedOrigins.includes(origin)) callback(null, true);
else callback(new Error('Not allowed by CORS'));
};
} else if (process.env.NODE_ENV?.toLowerCase() === 'production') {
corsOrigin = false;
} else {
corsOrigin = true;
}
const shouldForceHttps = process.env.FORCE_HTTPS?.toLowerCase() === 'true';
// HSTS is worth enabling any time we're serving production traffic,
// not only when FORCE_HTTPS is set. Self-hosters behind Traefik /
// Caddy / Cloudflare Tunnel typically leave FORCE_HTTPS unset (the
// proxy handles the redirect for them), and the previous "HSTS off by
// default" meant those instances never advertised HSTS at all.
//
// `includeSubDomains` stays OFF by default on purpose: an instance
// running on an apex domain would otherwise force HTTPS on every
// sibling subdomain the same operator may still be running over plain
// HTTP. Operators who want the stricter policy opt in with
// `HSTS_INCLUDE_SUBDOMAINS=true`.
const hstsActive = shouldForceHttps || process.env.NODE_ENV === 'production';
const hstsIncludeSubdomains = process.env.HSTS_INCLUDE_SUBDOMAINS === 'true';
// RFC 8414 / RFC 9728 / RFC 7591: discovery docs and DCR are world-readable/writable.
// /mcp needs open CORS so external MCP clients (ChatGPT, Claude.ai, Inspector) can call it
// with Bearer tokens from any origin. /oauth/register and /oauth/authorize need it for
// browser-based DCR/authorization preflights — the global cors({ origin: false }) would
// answer OPTIONS without Access-Control-Allow-Origin before the SDK's own cors() runs.
// All /.well-known/* paths get open CORS so clients probing openid-configuration or the
// RFC 8414 path-suffixed AS metadata form don't get CORS-blocked (they get 404 JSON instead).
app.use(
(req: Request, _res: Response, next: NextFunction) => {
if (
req.path.startsWith('/.well-known/') ||
req.path === '/oauth/register' ||
req.path === '/oauth/authorize' ||
req.path === '/oauth/userinfo' ||
req.path === '/mcp'
) {
cors({ origin: '*', credentials: false })(req, _res, next);
} else {
next();
}
},
);
app.use(cors({ origin: corsOrigin, credentials: true }));
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
imgSrc: ["'self'", "data:", "blob:", "https:"],
connectSrc: [
"'self'", "ws:", "wss:",
"https://nominatim.openstreetmap.org", "https://overpass-api.de",
"https://places.googleapis.com", "https://api.openweathermap.org",
"https://en.wikipedia.org", "https://commons.wikimedia.org",
"https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org",
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson",
"https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/",
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com"
],
workerSrc: ["'self'", "blob:"],
childSrc: ["'self'", "blob:"],
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
objectSrc: ["'none'"],
frameSrc: ["'none'"],
frameAncestors: ["'self'"],
upgradeInsecureRequests: shouldForceHttps ? [] : null
}
},
crossOriginEmbedderPolicy: false,
hsts: hstsActive ? { maxAge: 31536000, includeSubDomains: hstsIncludeSubdomains } : false,
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
if (shouldForceHttps) {
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path === '/api/health') return next();
if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next();
res.redirect(301, 'https://' + req.headers.host + req.url);
});
}
if (bodyParser) {
app.use(express.json({ limit: '100kb' }));
app.use(express.urlencoded({ extended: true }));
}
app.use(cookieParser());
app.use(enforceGlobalMfaPolicy);
// Request logging with sensitive field redaction
const SENSITIVE_KEYS = new Set(['password', 'new_password', 'current_password', 'token', 'jwt', 'authorization', 'cookie', 'client_secret', 'mfa_token', 'code', 'smtp_pass']);
const redact = (value: unknown): unknown => {
if (!value || typeof value !== 'object') return value;
if (Array.isArray(value)) return (value as unknown[]).map(redact);
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = SENSITIVE_KEYS.has(k.toLowerCase()) ? '[REDACTED]' : redact(v);
}
return out;
};
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path === '/api/health') return next();
const startedAt = Date.now();
res.on('finish', () => {
const ms = Date.now() - startedAt;
if (res.statusCode >= 500) {
logError(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
} else if (res.statusCode === 401 || res.statusCode === 403) {
logDebug(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
} else if (res.statusCode >= 400) {
logWarn(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
}
const q = Object.keys(req.query).length ? ` query=${JSON.stringify(redact(req.query))}` : '';
const b = req.body && Object.keys(req.body).length ? ` body=${JSON.stringify(redact(req.body))}` : '';
logDebug(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}${q}${b}`);
});
next();
});
}
+27 -1
View File
@@ -17,7 +17,33 @@ server/src/nest/<domain>/<domain>.module.ts # registered in app.module.t
Add the prefix to `DEFAULT_NEST_PREFIXES` in `strangler.ts` to route it to Nest
(operators can override at runtime via the `NEST_PREFIXES` env var — instant
rollback, no redeploy).
rollback, no redeploy). Trip-scoped mounts use a pattern prefix with a `:param`
segment (e.g. `/api/trips/:tripId/packing`); the matcher routes only that nested
mount to Nest and leaves the sibling trip routes (days, places, ...) on Express.
## Migrated so far
- **Phase 1 (leaf):** weather, airports, config (public), system-notices, maps,
categories, tags, notifications, atlas.
- **Phase 2 (trip sub-domains):** vacay (addon), packing, todo.
## Cross-cutting Foundation pieces
- `common/idempotency.interceptor.ts` — global `APP_INTERCEPTOR` replaying the
client's `X-Idempotency-Key` on mutations, mirroring the legacy
`applyIdempotency` middleware so retried writes don't double-apply.
- `strangler.ts` — supports both static prefixes and `:param` pattern prefixes.
## Parity gotchas worth remembering
- A POST that answers with `res.json` in Express stays **200**; add `@HttpCode(200)`
(Nest defaults POST to 201). Creates that Express sends as 201 need nothing.
- Static sub-routes that collide with a `:id` param (e.g. `/in-app/all` vs
`/in-app/:id`, `/reorder` vs `/:id`) must be declared **before** the param route.
- Reproduce bespoke admin/error wording exactly — e.g. notifications' `test-smtp`
returns `{ error: 'Admin only' }`, not the AdminGuard's `Admin access required`.
- Trip-scoped routes verify trip access (404) and the relevant permission (403)
per handler and forward `X-Socket-Id` to the WebSocket broadcast.
## Parity is law
+339
View File
@@ -0,0 +1,339 @@
import { Body, Controller, Delete, Get, HttpCode, HttpException, NotFoundException, Param, Post, Put, Query, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { AdminService } from './admin.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdminGuard } from '../auth/admin.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { writeAudit, getClientIp, logInfo } from '../../services/auditLog';
import { send as sendNotification } from '../../services/notificationService';
import type { User } from '../../types';
/** Throw the legacy {error,status} envelope when a service call reports failure. */
function ok<T>(result: T): Exclude<T, { error: string }> {
if (result && typeof result === 'object' && 'error' in (result as Record<string, unknown>)) {
const r = result as unknown as { error: string; status?: number };
throw new HttpException({ error: r.error }, r.status ?? 400);
}
return result as Exclude<T, { error: string }>;
}
/**
* /api/admin admin-only control surface (users, stats, permissions, audit log,
* OIDC settings, invites, feature toggles, packing templates, addons, MCP/OAuth
* sessions, JWT rotation, default user settings).
*
* Byte-identical to the legacy Express route (server/src/routes/admin.ts):
* admin-gated, the {error,status} envelopes, the audit-log writes, the MCP
* session invalidation on addon/collab changes, create-201 vs the rest 200, and
* the dev-only test-notification endpoint (404 outside development).
*/
@Controller('api/admin')
@UseGuards(JwtAuthGuard, AdminGuard)
export class AdminController {
constructor(private readonly admin: AdminService) {}
// ── Users ──
@Get('users')
listUsers() { return { users: this.admin.listUsers() }; }
@Post('users')
@HttpCode(201)
createUser(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) {
const result = ok(this.admin.createUser(body));
writeAudit({ userId: user.id, action: 'admin.user_create', resource: String(result.insertedId), ip: getClientIp(req), details: result.auditDetails });
return { user: result.user };
}
@Put('users/:id')
updateUser(@CurrentUser() user: User, @Param('id') id: string, @Body() body: unknown, @Req() req: Request) {
const result = ok(this.admin.updateUser(id, body));
writeAudit({ userId: user.id, action: 'admin.user_update', resource: String(id), ip: getClientIp(req), details: { targetUser: result.previousEmail, fields: result.changed } });
logInfo(`Admin ${user.email} edited user ${result.previousEmail} (fields: ${result.changed.join(', ')})`);
return { user: result.user };
}
@Delete('users/:id')
deleteUser(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
const result = ok(this.admin.deleteUser(id, user.id));
writeAudit({ userId: user.id, action: 'admin.user_delete', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } });
logInfo(`Admin ${user.email} deleted user ${result.email}`);
return { success: true };
}
// ── Stats / permissions / audit ──
@Get('stats')
stats() { return this.admin.getStats(); }
@Get('permissions')
permissions() { return this.admin.getPermissions(); }
@Put('permissions')
savePermissions(@CurrentUser() user: User, @Body() body: { permissions?: unknown }, @Req() req: Request) {
if (!body.permissions || typeof body.permissions !== 'object') {
throw new HttpException({ error: 'permissions object required' }, 400);
}
const result = this.admin.savePermissions(body.permissions as unknown as Parameters<AdminService['savePermissions']>[0]);
writeAudit({ userId: user.id, action: 'admin.permissions_update', resource: 'permissions', ip: getClientIp(req), details: body.permissions as Record<string, unknown> });
return { success: true, permissions: result.permissions, ...(result.skipped.length ? { skipped: result.skipped } : {}) };
}
@Get('audit-log')
auditLog(@Query() query: { limit?: string; offset?: string }) { return this.admin.getAuditLog(query); }
// ── OIDC ──
@Get('oidc')
getOidc() { return this.admin.getOidcSettings(); }
@Put('oidc')
updateOidc(@CurrentUser() user: User, @Body() body: { issuer?: string } & Record<string, unknown>, @Req() req: Request) {
const result = this.admin.updateOidcSettings(body);
if (result.error) {
throw new HttpException({ error: result.error }, result.status || 400);
}
writeAudit({ userId: user.id, action: 'admin.oidc_update', ip: getClientIp(req), details: { issuer_set: !!body.issuer } });
return { success: true };
}
@Post('save-demo-baseline')
@HttpCode(200)
saveDemoBaseline(@CurrentUser() user: User, @Req() req: Request) {
const result = this.admin.saveDemoBaseline();
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
writeAudit({ userId: user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) });
return { success: true, message: result.message };
}
// ── GitHub / version ──
@Get('github-releases')
async githubReleases(@Query('per_page') perPage = '10', @Query('page') page = '1') {
return this.admin.getGithubReleases(String(perPage), String(page));
}
@Get('version-check')
async versionCheck() { return this.admin.checkVersion(); }
// ── Admin notification preferences ──
@Get('notification-preferences')
getNotificationPrefs(@CurrentUser() user: User) { return this.admin.getPreferencesMatrix(user.id, user.role); }
@Put('notification-preferences')
setNotificationPrefs(@CurrentUser() user: User, @Body() body: unknown) {
this.admin.setAdminPreferences(user.id, body);
return this.admin.getPreferencesMatrix(user.id, user.role);
}
// ── Invites ──
@Get('invites')
listInvites() { return { invites: this.admin.listInvites() }; }
@Post('invites')
@HttpCode(201)
createInvite(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) {
const result = this.admin.createInvite(user.id, body);
writeAudit({ userId: user.id, action: 'admin.invite_create', resource: String(result.inviteId), ip: getClientIp(req), details: { max_uses: result.uses, expires_in_days: result.expiresInDays } });
return { invite: result.invite };
}
@Delete('invites/:id')
deleteInvite(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
ok(this.admin.deleteInvite(id));
writeAudit({ userId: user.id, action: 'admin.invite_delete', resource: String(id), ip: getClientIp(req) });
return { success: true };
}
// ── Feature toggles ──
@Get('bag-tracking')
getBagTracking() { return this.admin.getBagTracking(); }
@Put('bag-tracking')
updateBagTracking(@CurrentUser() user: User, @Body() body: { enabled?: unknown }, @Req() req: Request) {
const result = this.admin.updateBagTracking(body.enabled);
writeAudit({ userId: user.id, action: 'admin.bag_tracking', ip: getClientIp(req), details: { enabled: result.enabled } });
return result;
}
@Get('places-photos')
getPlacesPhotos() { return this.admin.getPlacesPhotos(); }
@Put('places-photos')
updatePlacesPhotos(@CurrentUser() user: User, @Body() body: { enabled?: unknown }, @Req() req: Request) {
if (typeof body.enabled !== 'boolean') throw new HttpException({ error: 'enabled must be a boolean' }, 400);
const result = this.admin.updatePlacesPhotos(body.enabled);
writeAudit({ userId: user.id, action: 'admin.places_photos', ip: getClientIp(req), details: { enabled: result.enabled } });
return result;
}
@Get('places-autocomplete')
getPlacesAutocomplete() { return this.admin.getPlacesAutocomplete(); }
@Put('places-autocomplete')
updatePlacesAutocomplete(@CurrentUser() user: User, @Body() body: { enabled?: unknown }, @Req() req: Request) {
if (typeof body.enabled !== 'boolean') throw new HttpException({ error: 'enabled must be a boolean' }, 400);
const result = this.admin.updatePlacesAutocomplete(body.enabled);
writeAudit({ userId: user.id, action: 'admin.places_autocomplete', ip: getClientIp(req), details: { enabled: result.enabled } });
return result;
}
@Get('places-details')
getPlacesDetails() { return this.admin.getPlacesDetails(); }
@Put('places-details')
updatePlacesDetails(@CurrentUser() user: User, @Body() body: { enabled?: unknown }, @Req() req: Request) {
if (typeof body.enabled !== 'boolean') throw new HttpException({ error: 'enabled must be a boolean' }, 400);
const result = this.admin.updatePlacesDetails(body.enabled);
writeAudit({ userId: user.id, action: 'admin.places_details', ip: getClientIp(req), details: { enabled: result.enabled } });
return result;
}
@Get('collab-features')
getCollabFeatures() { return this.admin.getCollabFeatures(); }
@Put('collab-features')
updateCollabFeatures(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) {
const result = this.admin.updateCollabFeatures(body);
this.admin.invalidateMcpSessions();
writeAudit({ userId: user.id, action: 'admin.collab_features', ip: getClientIp(req), details: result });
return result;
}
// ── Packing templates ──
@Get('packing-templates')
listPackingTemplates() { return { templates: this.admin.listPackingTemplates() }; }
@Get('packing-templates/:id')
getPackingTemplate(@Param('id') id: string) { return ok(this.admin.getPackingTemplate(id)); }
@Post('packing-templates')
@HttpCode(201)
createPackingTemplate(@CurrentUser() user: User, @Body() body: { name?: unknown }) {
return ok(this.admin.createPackingTemplate(body.name, user.id));
}
@Put('packing-templates/:id')
updatePackingTemplate(@Param('id') id: string, @Body() body: unknown) { return ok(this.admin.updatePackingTemplate(id, body)); }
@Delete('packing-templates/:id')
deletePackingTemplate(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
const result = ok(this.admin.deletePackingTemplate(id));
writeAudit({ userId: user.id, action: 'admin.packing_template_delete', resource: String(id), ip: getClientIp(req), details: { name: result.name } });
return { success: true };
}
@Post('packing-templates/:id/categories')
@HttpCode(201)
createTemplateCategory(@Param('id') id: string, @Body() body: { name?: unknown }) {
return ok(this.admin.createTemplateCategory(id, body.name));
}
@Put('packing-templates/:templateId/categories/:catId')
updateTemplateCategory(@Param('templateId') templateId: string, @Param('catId') catId: string, @Body() body: unknown) {
return ok(this.admin.updateTemplateCategory(templateId, catId, body));
}
@Delete('packing-templates/:templateId/categories/:catId')
deleteTemplateCategory(@Param('templateId') templateId: string, @Param('catId') catId: string) {
ok(this.admin.deleteTemplateCategory(templateId, catId));
return { success: true };
}
@Post('packing-templates/:templateId/categories/:catId/items')
@HttpCode(201)
createTemplateItem(@Param('templateId') templateId: string, @Param('catId') catId: string, @Body() body: { name?: unknown }) {
return ok(this.admin.createTemplateItem(templateId, catId, body.name));
}
@Put('packing-templates/:templateId/items/:itemId')
updateTemplateItem(@Param('itemId') itemId: string, @Body() body: unknown) { return ok(this.admin.updateTemplateItem(itemId, body)); }
@Delete('packing-templates/:templateId/items/:itemId')
deleteTemplateItem(@Param('itemId') itemId: string) {
ok(this.admin.deleteTemplateItem(itemId));
return { success: true };
}
// ── Addons ──
@Get('addons')
listAddons() { return { addons: this.admin.listAddons() }; }
@Put('addons/:id')
updateAddon(@CurrentUser() user: User, @Param('id') id: string, @Body() body: unknown, @Req() req: Request) {
const result = ok(this.admin.updateAddon(id, body));
writeAudit({ userId: user.id, action: 'admin.addon_update', resource: String(id), ip: getClientIp(req), details: result.auditDetails });
this.admin.invalidateMcpSessions();
return { addon: result.addon };
}
// ── MCP tokens / OAuth sessions ──
@Get('mcp-tokens')
listMcpTokens() { return { tokens: this.admin.listMcpTokens() }; }
@Delete('mcp-tokens/:id')
deleteMcpToken(@Param('id') id: string) {
ok(this.admin.deleteMcpToken(id));
return { success: true };
}
@Get('oauth-sessions')
listOAuthSessions() { return { sessions: this.admin.listOAuthSessions() }; }
@Delete('oauth-sessions/:id')
revokeOAuthSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
ok(this.admin.revokeOAuthSession(id));
writeAudit({ userId: user.id, action: 'admin.oauth_session.revoke', resource: String(id), ip: getClientIp(req) });
return { success: true };
}
// ── JWT rotation ──
@Post('rotate-jwt-secret')
@HttpCode(200)
rotateJwtSecret(@CurrentUser() user: User, @Req() req: Request) {
const result = this.admin.rotateJwtSecret();
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
writeAudit({ userId: user.id, action: 'admin.rotate_jwt_secret', ip: getClientIp(req) });
return { success: true };
}
// ── Default user settings ──
@Get('default-user-settings')
getDefaultUserSettings() { return this.admin.getAdminUserDefaults(); }
@Put('default-user-settings')
setDefaultUserSettings(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
throw new HttpException({ error: 'Object body required' }, 400);
}
try {
this.admin.setAdminUserDefaults(body as unknown as Record<string, unknown>);
writeAudit({ userId: user.id, action: 'admin.default_user_settings_update', ip: getClientIp(req), details: body as Record<string, unknown> });
return this.admin.getAdminUserDefaults();
} catch (err) {
throw new HttpException({ error: err instanceof Error ? err.message : String(err) }, 400);
}
}
// ── Dev-only: test notification (404 outside development, mirroring the conditional mount) ──
@Post('dev/test-notification')
@HttpCode(200)
async devTestNotification(@CurrentUser() user: User, @Body() body: { event?: string; scope?: string; targetId?: number; params?: Record<string, unknown>; inApp?: boolean }) {
if (process.env.NODE_ENV?.toLowerCase() !== 'development') {
throw new NotFoundException();
}
try {
await sendNotification({
event: body.event ?? 'trip_reminder',
actorId: user.id,
scope: body.scope ?? 'user',
targetId: body.targetId ?? user.id,
params: { actor: user.email, ...(body.params ?? {}) },
inApp: body.inApp,
} as unknown as Parameters<typeof sendNotification>[0]);
return { success: true };
} catch (err) {
throw new HttpException({ error: err instanceof Error ? err.message : String(err) }, 400);
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
@Module({
controllers: [AdminController],
providers: [AdminService],
})
export class AdminModule {}
+79
View File
@@ -0,0 +1,79 @@
import { Injectable } from '@nestjs/common';
import * as svc from '../../services/adminService';
import { getAdminUserDefaults, setAdminUserDefaults } from '../../services/settingsService';
import { invalidateMcpSessions } from '../../mcp';
import { getPreferencesMatrix, setAdminPreferences } from '../../services/notificationPreferencesService';
/**
* Thin Nest wrapper around the existing admin service (+ the settings,
* MCP-session and notification-preference helpers the legacy route used). All
* business logic, audit-relevant return shapes and the addon/MCP invalidation
* reuse the legacy code unchanged.
*/
@Injectable()
export class AdminService {
// Users
listUsers() { return svc.listUsers(); }
createUser(body: unknown) { return svc.createUser(body as Parameters<typeof svc.createUser>[0]); }
updateUser(id: string, body: unknown) { return svc.updateUser(id, body as Parameters<typeof svc.updateUser>[1]); }
deleteUser(id: string, actingUserId: number) { return svc.deleteUser(id, actingUserId); }
getStats() { return svc.getStats(); }
getPermissions() { return svc.getPermissions(); }
savePermissions(permissions: Parameters<typeof svc.savePermissions>[0]) { return svc.savePermissions(permissions); }
getAuditLog(query: { limit?: string; offset?: string }) { return svc.getAuditLog(query); }
getOidcSettings() { return svc.getOidcSettings(); }
updateOidcSettings(body: unknown) { return svc.updateOidcSettings(body as Parameters<typeof svc.updateOidcSettings>[0]); }
saveDemoBaseline() { return svc.saveDemoBaseline(); }
getGithubReleases(perPage: string, page: string) { return svc.getGithubReleases(perPage, page); }
checkVersion() { return svc.checkVersion(); }
// Invites
listInvites() { return svc.listInvites(); }
createInvite(userId: number, body: unknown) { return svc.createInvite(userId, body as Parameters<typeof svc.createInvite>[1]); }
deleteInvite(id: string) { return svc.deleteInvite(id); }
// Feature toggles
getBagTracking() { return svc.getBagTracking(); }
updateBagTracking(enabled: unknown) { return svc.updateBagTracking(enabled as boolean); }
getPlacesPhotos() { return svc.getPlacesPhotos(); }
updatePlacesPhotos(enabled: boolean) { return svc.updatePlacesPhotos(enabled); }
getPlacesAutocomplete() { return svc.getPlacesAutocomplete(); }
updatePlacesAutocomplete(enabled: boolean) { return svc.updatePlacesAutocomplete(enabled); }
getPlacesDetails() { return svc.getPlacesDetails(); }
updatePlacesDetails(enabled: boolean) { return svc.updatePlacesDetails(enabled); }
getCollabFeatures() { return svc.getCollabFeatures(); }
updateCollabFeatures(body: unknown) { return svc.updateCollabFeatures(body as Parameters<typeof svc.updateCollabFeatures>[0]); }
// Packing templates
listPackingTemplates() { return svc.listPackingTemplates(); }
getPackingTemplate(id: string) { return svc.getPackingTemplate(id); }
createPackingTemplate(name: unknown, userId: number) { return svc.createPackingTemplate(name as string, userId); }
updatePackingTemplate(id: string, body: unknown) { return svc.updatePackingTemplate(id, body as Parameters<typeof svc.updatePackingTemplate>[1]); }
deletePackingTemplate(id: string) { return svc.deletePackingTemplate(id); }
createTemplateCategory(templateId: string, name: unknown) { return svc.createTemplateCategory(templateId, name as string); }
updateTemplateCategory(templateId: string, catId: string, body: unknown) { return svc.updateTemplateCategory(templateId, catId, body as Parameters<typeof svc.updateTemplateCategory>[2]); }
deleteTemplateCategory(templateId: string, catId: string) { return svc.deleteTemplateCategory(templateId, catId); }
createTemplateItem(templateId: string, catId: string, name: unknown) { return svc.createTemplateItem(templateId, catId, name as string); }
updateTemplateItem(itemId: string, body: unknown) { return svc.updateTemplateItem(itemId, body as Parameters<typeof svc.updateTemplateItem>[1]); }
deleteTemplateItem(itemId: string) { return svc.deleteTemplateItem(itemId); }
// Addons + tokens + sessions
listAddons() { return svc.listAddons(); }
updateAddon(id: string, body: unknown) { return svc.updateAddon(id, body as Parameters<typeof svc.updateAddon>[1]); }
listMcpTokens() { return svc.listMcpTokens(); }
deleteMcpToken(id: string) { return svc.deleteMcpToken(id); }
listOAuthSessions() { return svc.listOAuthSessions(); }
revokeOAuthSession(id: string) { return svc.revokeOAuthSession(id); }
rotateJwtSecret() { return svc.rotateJwtSecret(); }
invalidateMcpSessions() { invalidateMcpSessions(); }
// Settings + notification preference helpers (non-admin-service modules)
getAdminUserDefaults() { return getAdminUserDefaults(); }
setAdminUserDefaults(body: Record<string, unknown>) { return setAdminUserDefaults(body); }
getPreferencesMatrix(userId: number, role: string) { return getPreferencesMatrix(userId, role, 'admin'); }
setAdminPreferences(userId: number, body: unknown) { return setAdminPreferences(userId, body as Parameters<typeof setAdminPreferences>[1]); }
}
@@ -0,0 +1,38 @@
import { Controller, Get, HttpException, Param, Query, UseGuards } from '@nestjs/common';
import type { Airport } from '@trek/shared';
import { AirportsService } from './airports.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
/**
* /api/airports typeahead search + single lookup by IATA code.
*
* Behaviour is byte-identical to the legacy Express route (server/src/routes/
* airports.ts): both endpoints require auth, an absent/non-string query answers
* with `[]` (not a 400), and an unknown IATA code 404s with the exact
* `{ error: 'Airport not found' }` body.
*
* The `search` route is declared before `:iata` so the static segment wins over
* the param, matching the legacy router's registration order.
*/
@Controller('api/airports')
@UseGuards(JwtAuthGuard)
export class AirportsController {
constructor(private readonly airports: AirportsService) {}
@Get('search')
search(@Query('q') q?: string | string[]): Airport[] {
// Express coerces a missing/array query to '' and returns [] for it.
const term = typeof q === 'string' ? q : '';
if (!term) return [];
return this.airports.search(term);
}
@Get(':iata')
findByIata(@Param('iata') iata: string): Airport {
const airport = this.airports.findByIata(iata);
if (!airport) {
throw new HttpException({ error: 'Airport not found' }, 404);
}
return airport;
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AirportsController } from './airports.controller';
import { AirportsService } from './airports.service';
/** Airports domain (L2 leaf module). Registered in AppModule. */
@Module({
controllers: [AirportsController],
providers: [AirportsService],
})
export class AirportsModule {}
@@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common';
import type { Airport } from '@trek/shared';
import { searchAirports, findByIata } from '../../services/airportService';
/**
* Thin Nest wrapper around the existing airport service. It delegates to the
* same `searchAirports` / `findByIata` functions the legacy route uses, so the
* in-memory dataset and lookup behaviour stay identical and unduplicated.
*/
@Injectable()
export class AirportsService {
search(query: string): Airport[] {
return searchAirports(query) as Airport[];
}
findByIata(code: string): Airport | null {
return findByIata(code) as Airport | null;
}
}
+34 -2
View File
@@ -1,23 +1,55 @@
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
import { DatabaseModule } from './database/database.module';
import { HealthController } from './health/health.controller';
import { HealthService } from './health/health.service';
import { WeatherModule } from './weather/weather.module';
import { AirportsModule } from './airports/airports.module';
import { ConfigModule } from './config/config.module';
import { SystemNoticesModule } from './system-notices/system-notices.module';
import { MapsModule } from './maps/maps.module';
import { CategoriesModule } from './categories/categories.module';
import { TagsModule } from './tags/tags.module';
import { NotificationsModule } from './notifications/notifications.module';
import { AtlasModule } from './atlas/atlas.module';
import { VacayModule } from './vacay/vacay.module';
import { PackingModule } from './packing/packing.module';
import { BudgetModule } from './budget/budget.module';
import { ReservationsModule } from './reservations/reservations.module';
import { DaysModule } from './days/days.module';
import { AssignmentsModule } from './assignments/assignments.module';
import { PlacesModule } from './places/places.module';
import { TripsModule } from './trips/trips.module';
import { TodoModule } from './todo/todo.module';
import { CollabModule } from './collab/collab.module';
import { FilesModule } from './files/files.module';
import { PhotosModule } from './photos/photos.module';
import { JourneyModule } from './journey/journey.module';
import { ShareModule } from './share/share.module';
import { SettingsModule } from './settings/settings.module';
import { BackupModule } from './backup/backup.module';
import { AuthModule } from './auth/auth.module';
import { OidcModule } from './oidc/oidc.module';
import { OauthModule } from './oauth/oauth.module';
import { AdminModule } from './admin/admin.module';
import { TrekExceptionFilter } from './common/trek-exception.filter';
import { IdempotencyInterceptor } from './common/idempotency.interceptor';
/**
* Root NestJS module for the incremental migration. Domain modules
* (weather, notifications, ...) get registered here as they are migrated.
*/
@Module({
imports: [DatabaseModule, WeatherModule],
imports: [DatabaseModule, WeatherModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule],
controllers: [HealthController],
providers: [
HealthService,
// Global error-envelope normaliser (DI-registered so it also catches
// framework-level exceptions like the not-found handler).
{ provide: APP_FILTER, useClass: TrekExceptionFilter },
// Replays the X-Idempotency-Key the client sends on every write, matching
// the legacy applyIdempotency middleware so retried mutations don't double-apply.
{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
],
})
export class AppModule {}
@@ -0,0 +1,189 @@
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpException,
Param,
Post,
Put,
UseGuards,
} from '@nestjs/common';
import type { User } from '../../types';
import { AssignmentsService } from './assignments.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
type Trip = NonNullable<ReturnType<AssignmentsService['verifyTripAccess']>>;
/** Shared trip-access guard (mirrors requireTripAccess → 404 "Trip not found"). */
function requireTrip(svc: AssignmentsService, tripId: string, user: User): Trip {
const trip = svc.verifyTripAccess(tripId, user.id);
if (!trip) {
throw new HttpException({ error: 'Trip not found' }, 404);
}
return trip;
}
function requireEdit(svc: AssignmentsService, trip: Trip, user: User): void {
if (!svc.canEdit(trip, user)) {
throw new HttpException({ error: 'No permission' }, 403);
}
}
/**
* /api/trips/:tripId/days/:dayId/assignments the day's ordered itinerary items.
*
* Byte-identical to the legacy Express route (server/src/routes/assignments.ts):
* trip access (404), 'day_edit' on mutations (403, GET is access-only), create
* 201 / rest 200, the bespoke "Day not found" / "Place not found" / "Assignment
* not found" bodies, the journey place-created hook, and WebSocket broadcasts.
*/
@Controller('api/trips/:tripId/days/:dayId/assignments')
@UseGuards(JwtAuthGuard)
export class DayAssignmentsController {
constructor(private readonly assignments: AssignmentsService) {}
@Get()
list(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('dayId') dayId: string) {
requireTrip(this.assignments, tripId, user);
if (!this.assignments.dayExists(dayId, tripId)) {
throw new HttpException({ error: 'Day not found' }, 404);
}
return { assignments: this.assignments.listDayAssignments(dayId) };
}
@Post()
create(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('dayId') dayId: string,
@Body() body: { place_id?: unknown; notes?: string | null },
@Headers('x-socket-id') socketId?: string,
) {
const trip = requireTrip(this.assignments, tripId, user);
requireEdit(this.assignments, trip, user);
if (!this.assignments.dayExists(dayId, tripId)) {
throw new HttpException({ error: 'Day not found' }, 404);
}
if (!this.assignments.placeExists(body.place_id, tripId)) {
throw new HttpException({ error: 'Place not found' }, 404);
}
const assignment = this.assignments.createAssignment(dayId, body.place_id, body.notes);
this.assignments.broadcast(tripId, 'assignment:created', { assignment }, socketId);
this.assignments.notifyPlaceCreated(tripId, body.place_id);
return { assignment };
}
@Put('reorder')
reorder(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('dayId') dayId: string,
@Body('orderedIds') orderedIds: number[],
@Headers('x-socket-id') socketId?: string,
) {
const trip = requireTrip(this.assignments, tripId, user);
requireEdit(this.assignments, trip, user);
if (!this.assignments.dayExists(dayId, tripId)) {
throw new HttpException({ error: 'Day not found' }, 404);
}
this.assignments.reorderAssignments(dayId, orderedIds);
this.assignments.broadcast(tripId, 'assignment:reordered', { dayId: Number(dayId), orderedIds }, socketId);
return { success: true };
}
@Delete(':id')
remove(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('dayId') dayId: string,
@Param('id') id: string,
@Headers('x-socket-id') socketId?: string,
) {
const trip = requireTrip(this.assignments, tripId, user);
requireEdit(this.assignments, trip, user);
if (!this.assignments.assignmentExistsInDay(id, dayId, tripId)) {
throw new HttpException({ error: 'Assignment not found' }, 404);
}
this.assignments.deleteAssignment(id);
this.assignments.broadcast(tripId, 'assignment:deleted', { assignmentId: Number(id), dayId: Number(dayId) }, socketId);
return { success: true };
}
}
/**
* /api/trips/:tripId/assignments/:id/* per-assignment ops (move, time,
* participants), independent of the day path. Same parity rules as above.
*/
@Controller('api/trips/:tripId/assignments')
@UseGuards(JwtAuthGuard)
export class AssignmentOpsController {
constructor(private readonly assignments: AssignmentsService) {}
@Put(':id/move')
move(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: { new_day_id?: unknown; order_index?: number },
@Headers('x-socket-id') socketId?: string,
) {
const trip = requireTrip(this.assignments, tripId, user);
requireEdit(this.assignments, trip, user);
const existing = this.assignments.getAssignmentForTrip(id, tripId);
if (!existing) {
throw new HttpException({ error: 'Assignment not found' }, 404);
}
if (!this.assignments.dayExists(String(body.new_day_id), tripId)) {
throw new HttpException({ error: 'Target day not found' }, 404);
}
const oldDayId = (existing as { day_id: number }).day_id;
const { assignment } = this.assignments.moveAssignment(id, body.new_day_id, body.order_index, oldDayId);
this.assignments.broadcast(tripId, 'assignment:moved', { assignment, oldDayId: Number(oldDayId), newDayId: Number(body.new_day_id) }, socketId);
return { assignment };
}
@Get(':id/participants')
participants(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string) {
requireTrip(this.assignments, tripId, user);
return { participants: this.assignments.getParticipants(id) };
}
@Put(':id/time')
time(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: { place_time?: string | null; end_time?: string | null },
@Headers('x-socket-id') socketId?: string,
) {
const trip = requireTrip(this.assignments, tripId, user);
requireEdit(this.assignments, trip, user);
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
throw new HttpException({ error: 'Assignment not found' }, 404);
}
const assignment = this.assignments.updateTime(id, body.place_time, body.end_time);
this.assignments.broadcast(tripId, 'assignment:updated', { assignment }, socketId);
return { assignment };
}
@Put(':id/participants')
setParticipants(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body('user_ids') userIds: unknown,
@Headers('x-socket-id') socketId?: string,
) {
const trip = requireTrip(this.assignments, tripId, user);
requireEdit(this.assignments, trip, user);
if (!Array.isArray(userIds)) {
throw new HttpException({ error: 'user_ids must be an array' }, 400);
}
const participants = this.assignments.setParticipants(id, userIds);
this.assignments.broadcast(tripId, 'assignment:participants', { assignmentId: Number(id), participants }, socketId);
return { participants };
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { DayAssignmentsController, AssignmentOpsController } from './assignments.controller';
import { AssignmentsService } from './assignments.service';
/**
* Assignments domain (S7 Phase 2 trip sub-domain). The day-assignments mount
* sits under the /api/trips/:tripId/days prefix (S6); the per-assignment ops use
* the /api/trips/:tripId/assignments prefix.
*/
@Module({
controllers: [DayAssignmentsController, AssignmentOpsController],
providers: [AssignmentsService],
})
export class AssignmentsModule {}
@@ -0,0 +1,83 @@
import { Injectable } from '@nestjs/common';
import { broadcast } from '../../websocket';
import { canAccessTrip } from '../../db/database';
import { checkPermission } from '../../services/permissions';
import type { User } from '../../types';
import * as svc from '../../services/assignmentService';
import { onPlaceCreated } from '../../services/journeyService';
type Trip = { user_id: number };
/**
* Thin Nest wrapper around the existing assignment service. Trip access mirrors
* the requireTripAccess middleware (canAccessTrip); mutations use 'day_edit'.
* The SQL, the move/reorder logic and the journey "place created" hook reuse the
* legacy code unchanged.
*/
@Injectable()
export class AssignmentsService {
verifyTripAccess(tripId: string, userId: number) {
return canAccessTrip(Number(tripId), userId) as Trip | null | undefined;
}
canEdit(trip: Trip, user: User): boolean {
return checkPermission('day_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
}
broadcast(tripId: string, event: string, payload: Record<string, unknown>, socketId: string | undefined): void {
broadcast(tripId, event, payload, socketId);
}
dayExists(dayId: string, tripId: string) {
return svc.dayExists(dayId, tripId);
}
placeExists(placeId: unknown, tripId: string) {
return svc.placeExists(placeId as never, tripId);
}
listDayAssignments(dayId: string) {
return svc.listDayAssignments(dayId);
}
createAssignment(dayId: string, placeId: unknown, notes?: string | null) {
return svc.createAssignment(dayId, placeId as never, notes as never);
}
/** Mirrors the legacy POST hook; non-fatal, like the route's try/catch. */
notifyPlaceCreated(tripId: string, placeId: unknown): void {
try { onPlaceCreated(Number(tripId), Number(placeId)); } catch { /* non-fatal */ }
}
assignmentExistsInDay(id: string, dayId: string, tripId: string) {
return svc.assignmentExistsInDay(id, dayId, tripId);
}
deleteAssignment(id: string): void {
svc.deleteAssignment(id);
}
reorderAssignments(dayId: string, orderedIds: number[]): void {
svc.reorderAssignments(dayId, orderedIds as never);
}
getAssignmentForTrip(id: string, tripId: string) {
return svc.getAssignmentForTrip(id, tripId);
}
moveAssignment(id: string, newDayId: unknown, orderIndex: number | undefined, oldDayId: unknown) {
return svc.moveAssignment(id, newDayId as never, orderIndex as never, oldDayId as never);
}
getParticipants(id: string) {
return svc.getParticipants(id);
}
updateTime(id: string, placeTime: unknown, endTime: unknown) {
return svc.updateTime(id, placeTime as never, endTime as never);
}
setParticipants(id: string, userIds: number[]) {
return svc.setParticipants(id, userIds);
}
}
+142
View File
@@ -0,0 +1,142 @@
import {
Body,
Controller,
Delete,
Get,
Header,
HttpCode,
HttpException,
Param,
Post,
Put,
Query,
Res,
UseGuards,
} from '@nestjs/common';
import type { Response } from 'express';
import type { RegionGeo } from '@trek/shared';
import type { User } from '../../types';
import { AtlasService } from './atlas.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
/**
* /api/addons/atlas visited countries/regions, region GeoJSON, bucket list.
*
* Byte-identical to the legacy Express route (server/src/routes/atlas.ts): all
* endpoints require auth; country/region codes are upper-cased; /regions is
* always no-store while /regions/geo is cached for a day only on a non-empty
* result; the mark POSTs answer 200 (not Nest's default 201); and the bespoke
* 400/404 bodies are reproduced exactly. No addon gate the legacy route has
* none, so adding one would break clients when the addon is off.
*/
@Controller('api/addons/atlas')
@UseGuards(JwtAuthGuard)
export class AtlasController {
constructor(private readonly atlas: AtlasService) {}
@Get('stats')
stats(@CurrentUser() user: User) {
return this.atlas.stats(user.id);
}
@Get('regions')
@Header('Cache-Control', 'no-cache, no-store')
regions(@CurrentUser() user: User) {
return this.atlas.visitedRegions(user.id);
}
@Get('regions/geo')
async regionGeo(
@Query('countries') countries: string | undefined,
@Res({ passthrough: true }) res: Response,
): Promise<RegionGeo> {
const list = (countries || '').split(',').filter(Boolean);
if (list.length === 0) {
return { type: 'FeatureCollection', features: [] };
}
const geo = await this.atlas.regionGeo(list);
// Cache only a non-empty result, matching the legacy route (the empty
// short-circuit above sends no Cache-Control header).
res.setHeader('Cache-Control', 'public, max-age=86400');
return geo;
}
@Get('country/:code')
countryPlaces(@CurrentUser() user: User, @Param('code') code: string) {
return this.atlas.countryPlaces(user.id, code.toUpperCase());
}
@Post('country/:code/mark')
@HttpCode(200)
markCountry(@CurrentUser() user: User, @Param('code') code: string): { success: boolean } {
this.atlas.markCountry(user.id, code.toUpperCase());
return { success: true };
}
@Delete('country/:code/mark')
unmarkCountry(@CurrentUser() user: User, @Param('code') code: string): { success: boolean } {
this.atlas.unmarkCountry(user.id, code.toUpperCase());
return { success: true };
}
@Post('region/:code/mark')
@HttpCode(200)
markRegion(
@CurrentUser() user: User,
@Param('code') code: string,
@Body('name') name?: string,
@Body('country_code') countryCode?: string,
): { success: boolean } {
if (!name || !countryCode) {
throw new HttpException({ error: 'name and country_code are required' }, 400);
}
this.atlas.markRegion(user.id, code.toUpperCase(), name, countryCode.toUpperCase());
return { success: true };
}
@Delete('region/:code/mark')
unmarkRegion(@CurrentUser() user: User, @Param('code') code: string): { success: boolean } {
this.atlas.unmarkRegion(user.id, code.toUpperCase());
return { success: true };
}
@Get('bucket-list')
bucketList(@CurrentUser() user: User) {
return { items: this.atlas.bucketList(user.id) };
}
@Post('bucket-list')
createBucketItem(
@CurrentUser() user: User,
@Body() body: { name?: string; lat?: number | null; lng?: number | null; country_code?: string | null; notes?: string | null; target_date?: string | null },
): { item: unknown } {
if (!body.name?.trim()) {
throw new HttpException({ error: 'Name is required' }, 400);
}
const { name, lat, lng, country_code, notes, target_date } = body;
return { item: this.atlas.createBucketItem(user.id, { name, lat, lng, country_code, notes, target_date }) };
}
@Put('bucket-list/:id')
updateBucketItem(
@CurrentUser() user: User,
@Param('id') id: string,
@Body() body: { name?: string; notes?: string; lat?: number | null; lng?: number | null; country_code?: string | null; target_date?: string | null },
): { item: unknown } {
const { name, notes, lat, lng, country_code, target_date } = body;
const item = this.atlas.updateBucketItem(user.id, id, { name, notes, lat, lng, country_code, target_date });
if (!item) {
throw new HttpException({ error: 'Item not found' }, 404);
}
return { item };
}
@Delete('bucket-list/:id')
deleteBucketItem(@CurrentUser() user: User, @Param('id') id: string): { success: boolean } {
if (!this.atlas.deleteBucketItem(user.id, id)) {
throw new HttpException({ error: 'Item not found' }, 404);
}
return { success: true };
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AtlasController } from './atlas.controller';
import { AtlasService } from './atlas.service';
/** Atlas addon domain (L7 leaf module). Registered in AppModule. */
@Module({
controllers: [AtlasController],
providers: [AtlasService],
})
export class AtlasModule {}

Some files were not shown because too many files have changed in this diff Show More