mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-23 07:11:46 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc7d8b5d12 |
@@ -0,0 +1,5 @@
|
||||
# Playwright E2E (FE7)
|
||||
e2e/.tmp/
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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))
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ↓{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 ↓{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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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) }} />
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -7,3 +7,4 @@ export {
|
||||
detectBrowserLanguage,
|
||||
SUPPORTED_LANGUAGES,
|
||||
} from './TranslationContext'
|
||||
export { TransHtml } from './TransHtml'
|
||||
|
||||
+35
-346
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 validate→consent 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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Generated
+1476
-3656
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.'}` } }],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user