diff --git a/.github/workflows/close-stale-invalid-titles.yml b/.github/workflows/close-stale-invalid-titles.yml index fa9fff4d3..9eb1ce338 100644 --- a/.github/workflows/close-stale-invalid-titles.yml +++ b/.github/workflows/close-stale-invalid-titles.yml @@ -9,6 +9,7 @@ permissions: jobs: close-stale: + if: github.repository == 'mauriceboe/TREK' runs-on: ubuntu-latest steps: - name: Close stale invalid-title issues diff --git a/.github/workflows/close-stale-wrong-branch.yml b/.github/workflows/close-stale-wrong-branch.yml index f0e133d6f..c1a41a3cd 100644 --- a/.github/workflows/close-stale-wrong-branch.yml +++ b/.github/workflows/close-stale-wrong-branch.yml @@ -10,6 +10,7 @@ permissions: jobs: close-stale: + if: github.repository == 'mauriceboe/TREK' runs-on: ubuntu-latest steps: - name: Close stale wrong-base-branch PRs diff --git a/.github/workflows/close-untitled-issues.yml b/.github/workflows/close-untitled-issues.yml index a9a94e950..0cd817682 100644 --- a/.github/workflows/close-untitled-issues.yml +++ b/.github/workflows/close-untitled-issues.yml @@ -9,6 +9,7 @@ permissions: jobs: check-title: + if: github.repository == 'mauriceboe/TREK' runs-on: ubuntu-latest steps: - name: Flag or redirect issue diff --git a/.github/workflows/docker-dev.yml b/.github/workflows/docker-dev.yml index 935e30889..d2e3ed070 100644 --- a/.github/workflows/docker-dev.yml +++ b/.github/workflows/docker-dev.yml @@ -18,6 +18,7 @@ concurrency: jobs: version-bump: + if: github.repository == 'mauriceboe/TREK' runs-on: ubuntu-latest outputs: version: ${{ steps.bump.outputs.VERSION }} diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 5014cca77..bd31e32f3 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -33,6 +33,7 @@ concurrency: jobs: version-bump: + if: github.repository == 'mauriceboe/TREK' runs-on: ubuntu-latest outputs: version: ${{ steps.bump.outputs.VERSION }} diff --git a/.github/workflows/enforce-target-branch.yml b/.github/workflows/enforce-target-branch.yml index f549ad631..54e3a0ecb 100644 --- a/.github/workflows/enforce-target-branch.yml +++ b/.github/workflows/enforce-target-branch.yml @@ -6,6 +6,7 @@ on: jobs: check-target: + if: github.repository == 'mauriceboe/TREK' runs-on: ubuntu-latest permissions: pull-requests: write diff --git a/.github/workflows/publish-plugin-sdk.yml b/.github/workflows/publish-plugin-sdk.yml index 7be1cb01c..705771351 100644 --- a/.github/workflows/publish-plugin-sdk.yml +++ b/.github/workflows/publish-plugin-sdk.yml @@ -14,6 +14,7 @@ permissions: jobs: publish: + if: github.repository == 'mauriceboe/TREK' runs-on: ubuntu-latest defaults: run: diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 88755200a..af22f82a0 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -11,6 +11,9 @@ permissions: jobs: scout: + # Docker Hub secrets are not exposed to pull requests from forks, so the + # Scout login can never succeed there. + if: github.repository == 'mauriceboe/TREK' && github.event.pull_request.head.repo.fork != true runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/wiki.yml b/.github/workflows/wiki.yml index 3526e38eb..8c6974520 100644 --- a/.github/workflows/wiki.yml +++ b/.github/workflows/wiki.yml @@ -17,6 +17,7 @@ concurrency: jobs: deploy: + if: github.repository == 'mauriceboe/TREK' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/charts/trek/templates/deployment.yaml b/charts/trek/templates/deployment.yaml index 37d52b1c1..6727b683f 100644 --- a/charts/trek/templates/deployment.yaml +++ b/charts/trek/templates/deployment.yaml @@ -6,6 +6,12 @@ metadata: app: {{ include "trek.name" . }} spec: replicas: 1 + # TREK is a single-writer SQLite app on a ReadWriteOnce PVC, so the default + # RollingUpdate would start a second pod holding the same volume before the old one + # exits — a Multi-Attach deadlock, or two processes on one travel.db. Recreate tears + # the old pod down first. Override to RollingUpdate only with a ReadWriteMany volume. + strategy: + type: {{ .Values.updateStrategy | default "Recreate" }} selector: matchLabels: app: {{ include "trek.name" . }} diff --git a/charts/trek/values.yaml b/charts/trek/values.yaml index a0523ce14..150e89851 100644 --- a/charts/trek/values.yaml +++ b/charts/trek/values.yaml @@ -4,6 +4,11 @@ image: # tag: latest pullPolicy: IfNotPresent +# Deployment update strategy. Recreate is the safe default for the single-writer SQLite +# DB on a ReadWriteOnce volume (the old pod is torn down before the new one starts). +# Set to RollingUpdate only if you back the data volume with ReadWriteMany storage. +updateStrategy: Recreate + # Optional image pull secrets for private registries imagePullSecrets: [] # - name: my-registry-secret diff --git a/client/index.html b/client/index.html index afbfc3541..9578ebd20 100644 --- a/client/index.html +++ b/client/index.html @@ -23,11 +23,6 @@ - - -
diff --git a/client/package.json b/client/package.json index dfd9b9428..e23463b02 100644 --- a/client/package.json +++ b/client/package.json @@ -34,6 +34,7 @@ "dexie": "^4.4.2", "drag-drop-touch": "^1.3.1", "heic-to": "^1.4.2", + "iso-3166-2": "^1.0.0", "leaflet": "^1.9.4", "lucide-react": "^0.344.0", "mapbox-gl": "^3.22.0", diff --git a/client/src/App.tsx b/client/src/App.tsx index 6b621dbbf..75c323e90 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -88,7 +88,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR } return ( -
+
{children}
diff --git a/client/src/api/client.ts b/client/src/api/client.ts index fd2165ef1..b3f05d041 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -239,6 +239,40 @@ apiClient.interceptors.response.use( } ) +/** + * POST a FormData body — the ONLY way this client should upload a file. + * + * The shared axios instance carries `timeout: 8000`, and axios' timeout is a whole- + * request deadline rather than an idle one. A file upload that takes longer than 8s to + * push its body — a phone photo on a slow uplink, a 500 MB document — is aborted + * mid-stream, which the server reports as a multer "Request aborted" (#1495). + * + * Every upload therefore has to opt out with `timeout: 0`. That opt-out used to be + * hand-written per call site, so it was forgotten on 7 of 15 — including the two 500 MB + * endpoints (documents, backup restore). Centralizing makes the correct behavior the + * default instead of something you have to remember. + * + * The Content-Type is set for clarity only: axios unsets it for FormData in the browser + * so the platform can generate the multipart boundary. + */ +export interface UploadOptions { + onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void + idempotencyKey?: string + signal?: AbortSignal +} + +export function postMultipart(url: string, formData: FormData, opts?: UploadOptions): Promise { + return apiClient.post(url, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}), + }, + timeout: 0, + onUploadProgress: opts?.onUploadProgress, + signal: opts?.signal, + }).then(r => r.data as T) +} + export const authApi = { register: (data: RegisterRequest) => apiClient.post('/auth/register', data).then(r => r.data), validateInvite: (token: string) => apiClient.get(`/auth/invite/${token}`).then(r => r.data), @@ -253,7 +287,7 @@ export const authApi = { updateSettings: (data: Record) => apiClient.put('/auth/me/settings', data).then(r => r.data), getSettings: () => apiClient.get('/auth/me/settings').then(r => r.data), listUsers: () => apiClient.get('/auth/users').then(r => r.data), - uploadAvatar: (formData: FormData) => apiClient.post('/auth/avatar', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data), + uploadAvatar: (formData: FormData) => postMultipart('/auth/avatar', formData), deleteAvatar: () => apiClient.delete('/auth/avatar').then(r => r.data), getAppConfig: () => apiClient.get('/auth/app-config').then(r => r.data), updateAppSettings: (data: Record) => apiClient.put('/auth/app-settings', data).then(r => r.data), @@ -334,7 +368,7 @@ export const tripsApi = { get: (id: number | string) => apiClient.get(`/trips/${id}`).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), + uploadCover: (id: number | string, formData: FormData) => postMultipart(`/trips/${id}/cover`, formData), searchCoverImages: (query: string) => apiClient.get('/trips/cover-images/search', { params: { query } }).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), @@ -370,14 +404,14 @@ export const placesApi = { if (opts?.waypoints !== undefined) fd.append('importWaypoints', String(opts.waypoints)) if (opts?.routes !== undefined) fd.append('importRoutes', String(opts.routes)) if (opts?.tracks !== undefined) fd.append('importTracks', String(opts.tracks)) - return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) + return postMultipart(`/trips/${tripId}/places/import/gpx`, fd) }, importMapFile: (tripId: number | string, file: File, opts?: { points?: boolean; paths?: boolean }) => { const fd = new FormData() fd.append('file', file) if (opts?.points !== undefined) fd.append('importPoints', String(opts.points)) if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths)) - return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) + return postMultipart(`/trips/${tripId}/places/import/map`, fd) }, importGoogleList: (tripId: number | string, url: string, enrich?: boolean) => apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data), @@ -470,7 +504,17 @@ export const adminApi = { pluginUpdate: (id: string) => apiClient.post(`/admin/plugins/${id}/update`).then(r => r.data), pluginUninstall: (id: string, deleteData: boolean) => apiClient.post(`/admin/plugins/${id}/uninstall`, { deleteData }).then(r => r.data), pluginRescan: () => apiClient.post('/admin/plugins/rescan').then(r => r.data), - pluginUpload: (file: File) => { const fd = new FormData(); fd.append('file', file); return apiClient.post('/admin/plugins/upload', fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) }, + pluginUpload: (file: File) => { const fd = new FormData(); fd.append('file', file); return postMultipart('/admin/plugins/upload', fd) }, + // Dev-link (dev-only): register a plugin from a local built dir + hot-reload it. + pluginLink: (path: string) => apiClient.post('/admin/plugins/link', { path }).then(r => r.data), + pluginReload: (id: string) => apiClient.post(`/admin/plugins/${id}/reload`).then(r => r.data), + // Operator-supplied egress hosts: a plugin talking to a SELF-HOSTED service can't name + // the operator's hostname in its manifest, so the admin adds it here. Saving re-spawns + // the plugin with the widened allow-list. + pluginEgressHosts: (id: string): Promise<{ supported: boolean; hosts: string[] }> => + apiClient.get(`/admin/plugins/${id}/egress-hosts`).then(r => r.data), + pluginSetEgressHosts: (id: string, hosts: string[]): Promise<{ hosts: string[] }> => + apiClient.put(`/admin/plugins/${id}/egress-hosts`, { hosts }).then(r => r.data), pluginErrors: (id: string) => apiClient.get(`/admin/plugins/${id}/errors`).then(r => r.data), pluginAudit: (id: string) => apiClient.get(`/admin/plugins/${id}/audit`).then(r => r.data), // Local LLM (Ollama) management for the AI-parsing addon. @@ -555,6 +599,51 @@ export const addonsApi = { enabled: () => apiClient.get('/addons').then(r => r.data), } +/** A host-rendered column/action a plugin contributes into a native planner view + * (reservations/places/day) via the tableContributor hook. Every field is bounded + + * normalized server-side; a column url is guaranteed http/https/mailto. */ +export type ViewContribution = + | { kind: 'column'; pluginId: string; entityId: number; id: string; label: string; value?: string; url?: string; icon?: string; tone: 'default' | 'success' | 'warn' | 'danger' } + | { kind: 'action'; pluginId: string; entityId: number; id: string; label: string; icon?: string; target: { kind: 'frame'; sub: string } | { kind: 'route'; method: 'GET' | 'POST'; sub: string } } + +/** A badge a plugin adds to a dashboard trip card via the tripCardProvider hook. + * Bounded + normalized server-side; the url is guaranteed http/https/mailto. */ +export interface TripCardBadge { + pluginId: string; tripId: number; id: string; label: string; + value?: string; icon?: string; tone: 'default' | 'success' | 'warn' | 'danger'; url?: string; +} + +export interface PluginMapMarker { + pluginId: string; id: string; lat: number; lng: number; + label?: string; popupText?: string; url?: string; icon?: string; + tone: 'default' | 'success' | 'warn' | 'danger' +} + +/** A text-only section a pdfSectionProvider plugin appends to the trip PDF export. + * Server-normalized: counts + lengths are capped, cells are plain strings. */ +export interface PluginPdfSection { + pluginId: string; title: string; paragraphs: string[]; + table?: { headers: string[]; rows: string[][] } +} + +/** A country tint layer an atlasLayerProvider plugin draws over the Atlas map for + * the signed-in user. Codes are ISO alpha-2 (server-validated), tone enum-whitelisted. */ +export interface PluginAtlasLayer { + pluginId: string; id: string; name?: string; + countries: Array<{ code: string; tone: 'default' | 'success' | 'warn' | 'danger'; label?: string }> +} + +export interface PluginUserSettingField { + key: string; label?: string | null; input_type?: string; placeholder?: string | null; + hint?: string | null; required?: boolean; secret?: boolean; + options?: Array<{ value: string; label: string }> +} + +/** A button a plugin contributes to its own settings page ("Test connection"). */ +export interface PluginAction { + key: string; label: string; hint?: string; danger: boolean +} + export const pluginsApi = { // Active plugins the client renders (page nav entries, dashboard widgets). active: () => apiClient.get('/plugins').then(r => r.data), @@ -565,6 +654,58 @@ export const pluginsApi = { // Validation/warning contributions from warningProvider plugins (#1429). Fail-safe. tripWarnings: (tripId: number) => apiClient.get(`/trip-warnings/${tripId}`).then(r => r.data as { warnings: Array<{ pluginId: string; level: 'info' | 'warning' | 'error'; message: string; dayId?: number; placeId?: number }> }), + // Host-rendered columns/actions plugins add into a native planner view via the + // tableContributor hook. Fetched once per view, keyed by entityId; fail-safe. + viewContributions: (view: 'reservations' | 'transports' | 'places' | 'day' | 'costs' | 'packing' | 'files' | 'todos', tripId: number | string) => + apiClient.get(`/view-contributions/${view}/${tripId}`).then(r => r.data as { contributions: ViewContribution[] }), + // Bounded markers plugins overlay on the trip map via the mapMarkerProvider hook + // (#587). Host-normalized + range-checked; fail-safe (skips slow/failing providers). + mapMarkers: (tripId: number | string) => + apiClient.get(`/map-markers/${tripId}`).then(r => r.data as { markers: PluginMapMarker[] }), + // Text-only sections plugins append to the trip PDF export via the + // pdfSectionProvider hook. Host-normalized (counts + lengths capped); fail-safe. + pdfSections: (tripId: number | string) => + apiClient.get(`/pdf-sections/${tripId}`).then(r => r.data as { sections: PluginPdfSection[] }), + // Country tint layers plugins draw over the Atlas map for the signed-in user via + // the atlasLayerProvider hook. No tripId — user-scoped server-side; fail-safe. + atlasLayers: () => + apiClient.get('/atlas-layers').then(r => r.data as { layers: PluginAtlasLayer[] }), + // Extra rows plugins add under a journal entry via the journalEntryProvider hook. + // Same shape + hardening as placeDetails (label/value/allowlisted url); fail-safe. + journalEntryRows: (entryId: number) => + apiClient.get(`/journal-entry-rows/${entryId}`).then(r => r.data as { providers: Array<{ pluginId: string; items: Array<{ label: string; value?: string; url?: string }> }> }), + // Badges plugins add to the dashboard trip cards via the tripCardProvider hook. + // One call for all visible cards; host access-checks each tripId + bounds every + // field (label/value/tone/allowlisted url); fail-safe. + tripCardContributions: (tripIds: Array) => + apiClient.get(`/trip-card-contributions?tripIds=${tripIds.join(',')}`).then(r => r.data as { contributions: TripCardBadge[] }), + // The signed-in user's OWN plugin activity log — every host-mediated action a + // plugin took bound to them, across all plugins, newest first. The user-facing + // half of the capability audit; what makes the broad read grants accountable. + myActivity: (limit = 200) => + apiClient.get(`/plugin-activity?limit=${limit}`).then(r => r.data as { activity: Array<{ ts: string; plugin_id: string; plugin_name: string | null; method: string; resource: string | null; code: string }> }), + // A user's OWN scope:'user' settings for a plugin (API key, prefs). Secrets are + // masked; the write only accepts declared user-scope keys. + userSettings: (id: string) => + apiClient.get(`/plugin-settings/${id}`).then(r => r.data as { + fields: PluginUserSettingField[] + config: Record + actions: PluginAction[] + }), + // Run a settings-page action the plugin declared ("Test connection"). It runs AS the + // caller, so it reads the caller's own settings. + runAction: (id: string, key: string) => + apiClient.post(`/plugin-settings/${id}/actions/${encodeURIComponent(key)}`) + .then(r => r.data as { ok: boolean; message?: string }), + saveUserSettings: (id: string, config: Record) => + apiClient.post(`/plugin-settings/${id}`, { config }).then(r => r.data as { config: Record }), + // Host-brokered outbound OAuth (the host owns the tokens; the plugin only triggers). + oauthStatus: (id: string) => + apiClient.get(`/plugin-oauth/${id}/status`).then(r => r.data as { configured: boolean; connected: boolean }), + oauthConnect: (id: string) => + apiClient.post(`/plugin-oauth/${id}/connect`).then(r => r.data as { authorizeUrl: string }), + oauthDisconnect: (id: string) => + apiClient.post(`/plugin-oauth/${id}/disconnect`).then(r => r.data as { connected: boolean }), // Call one of a plugin's own declared routes through the host proxy. `sub` is // supplied by untrusted plugin code (the trekBridge forwards it verbatim), so it // MUST stay inside the plugin's own /plugins/:id/ namespace. We resolve it with @@ -624,27 +765,12 @@ export const journeyApi = { 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 }) => - apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { - headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) }, - timeout: 0, - onUploadProgress: opts?.onUploadProgress, - signal: opts?.signal, - }).then(r => r.data), - uploadGalleryPhotos: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) => - apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { - headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) }, - timeout: 0, - onUploadProgress: opts?.onUploadProgress, - signal: opts?.signal, - }).then(r => r.data), - uploadGalleryVideo: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) => - apiClient.post(`/journeys/${journeyId}/gallery/video`, formData, { - headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) }, - timeout: 0, - onUploadProgress: opts?.onUploadProgress, - signal: opts?.signal, - }).then(r => r.data), + uploadPhotos: (entryId: number, formData: FormData, opts?: UploadOptions) => + postMultipart(`/journeys/entries/${entryId}/photos`, formData, opts), + uploadGalleryPhotos: (journeyId: number, formData: FormData, opts?: UploadOptions) => + postMultipart(`/journeys/${journeyId}/gallery/photos`, formData, opts), + uploadGalleryVideo: (journeyId: number, formData: FormData, opts?: UploadOptions) => + postMultipart(`/journeys/${journeyId}/gallery/video`, formData, opts), addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) } 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, mediaTypes?: string[]) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) }).then(r => r.data), @@ -655,7 +781,7 @@ export const journeyApi = { deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data), // Cover - uploadCover: (id: number, formData: FormData) => apiClient.post(`/journeys/${id}/cover`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data), + uploadCover: (id: number, formData: FormData) => postMultipart(`/journeys/${id}/cover`, formData), // Contributors addContributor: (id: number, userId: number, role: string) => apiClient.post(`/journeys/${id}/contributors`, { user_id: userId, role }).then(r => r.data), @@ -711,9 +837,7 @@ export const budgetApi = { export const filesApi = { list: (tripId: number | string, trash?: boolean) => apiClient.get(`/trips/${tripId}/files`, { params: trash ? { trash: 'true' } : {} }).then(r => r.data), - upload: (tripId: number | string, formData: FormData) => apiClient.post(`/trips/${tripId}/files`, formData, { - headers: { 'Content-Type': 'multipart/form-data' } - }).then(r => r.data), + upload: (tripId: number | string, formData: FormData, opts?: UploadOptions) => postMultipart(`/trips/${tripId}/files`, formData, opts), 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), @@ -738,7 +862,7 @@ export const reservationsApi = { fd.append('mode', mode) // No client-side timeout: kitinerary + LLM extraction routinely exceeds the // global 8s default (a cold local model alone can take ~45s). - return apiClient.post(`/trips/${tripId}/reservations/import/booking`, fd, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 0 }).then(r => r.data) + return postMultipart(`/trips/${tripId}/reservations/import/booking`, fd) }, importBookingConfirm: (tripId: number | string, items: BookingImportPreviewItem[]): Promise => apiClient.post(`/trips/${tripId}/reservations/import/booking/confirm`, { items }).then(r => r.data), @@ -748,7 +872,7 @@ export const reservationsApi = { const fd = new FormData() for (const f of files) fd.append('files', f) fd.append('mode', mode) - return apiClient.post(`/trips/${tripId}/reservations/import/booking/async`, fd, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 0 }).then(r => r.data) + return postMultipart(`/trips/${tripId}/reservations/import/booking/async`, fd) }, // Poll a background job — recovery path when a WebSocket push was missed. importJobStatus: (tripId: number | string, jobId: string): Promise<{ status: 'running' | 'done' | 'error'; done: number; total: number; result?: BookingImportPreviewResponse; error?: string }> => @@ -811,7 +935,7 @@ export const collabApi = { 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), + uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => postMultipart(`/trips/${tripId}/collab/notes/${noteId}/files`, formData), 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: CollabPollCreateRequest) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data), @@ -846,7 +970,7 @@ export const backupApi = { uploadRestore: (file: File) => { const form = new FormData() form.append('backup', file) - return apiClient.post('/backup/upload-restore', form, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) + return postMultipart('/backup/upload-restore', form) }, getAutoSettings: () => apiClient.get('/backup/auto-settings').then(r => r.data), setAutoSettings: (settings: Record) => apiClient.put('/backup/auto-settings', settings).then(r => r.data), @@ -883,6 +1007,10 @@ export const notificationsApi = { testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testSmtp')), testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testWebhook')), testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testNtfy')), + // Generic channel test — this is how a PLUGIN channel's "Send test" button works. + testChannel: (channelId: string) => + apiClient.post(`/notifications/test/${encodeURIComponent(channelId)}`) + .then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testChannel')), } export const inAppNotificationsApi = { diff --git a/client/src/api/collections.ts b/client/src/api/collections.ts index f16ae4dbb..7c682bb32 100644 --- a/client/src/api/collections.ts +++ b/client/src/api/collections.ts @@ -1,4 +1,4 @@ -import apiClient from './client' +import apiClient, { postMultipart } from './client' import type { AxiosResponse } from 'axios' import type { CollectionListResponse, @@ -56,7 +56,7 @@ export const collectionsApi = { update: (id: number, body: CollectionUpdateRequest): Promise<{ collection: Collection }> => ax.patch(`${base}/${id}`, body satisfies CollectionUpdateRequest).then((r: AxiosResponse) => r.data), uploadCover: (id: number, formData: FormData): Promise => - ax.post(`${base}/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then((r: AxiosResponse) => r.data), + postMultipart(`${base}/${id}/cover`, formData), remove: (id: number): Promise => ax.delete(`${base}/${id}`).then((r: AxiosResponse) => r.data), reorder: (orderedIds: number[]): Promise => diff --git a/client/src/api/uploadTimeout.test.ts b/client/src/api/uploadTimeout.test.ts new file mode 100644 index 000000000..9478c70e2 --- /dev/null +++ b/client/src/api/uploadTimeout.test.ts @@ -0,0 +1,75 @@ +// FE-API-UPLOAD-001 to FE-API-UPLOAD-013 +// +// The shared axios instance carries timeout: 8000, and axios' timeout is a whole-request +// deadline — not an idle one. Any upload whose body takes longer than 8s to push is +// aborted mid-stream and the server reports a multer "Request aborted" (#1495). +// +// The original fix added `timeout: 0` to the three cover uploads by hand, which left the +// same bug live on 7 other endpoints — including the two that accept 500 MB (documents +// and backup restore). Every multipart call now goes through postMultipart(), so this +// suite pins ALL of them, not just the covers. +import { describe, it, expect, vi, afterEach } from 'vitest' +import { + apiClient, + authApi, + tripsApi, + placesApi, + adminApi, + journeyApi, + filesApi, + reservationsApi, + collabApi, + backupApi, +} from './client' +import { collectionsApi } from './collections' + +describe('every multipart upload disables the global request timeout', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + function spyPost() { + return vi.spyOn(apiClient, 'post').mockResolvedValue({ data: {} } as any) + } + + const fd = () => new FormData() + const file = () => new File(['x'], 'f.bin') + + // [id, description, invoke, expected url] + const cases: [string, string, () => Promise, string][] = [ + ['FE-API-UPLOAD-001', 'authApi.uploadAvatar (5 MB)', () => authApi.uploadAvatar(fd()), '/auth/avatar'], + ['FE-API-UPLOAD-002', 'tripsApi.uploadCover (20 MB)', () => tripsApi.uploadCover(7, fd()), '/trips/7/cover'], + ['FE-API-UPLOAD-003', 'placesApi.importGpx (10 MB)', () => placesApi.importGpx(7, file()), '/trips/7/places/import/gpx'], + ['FE-API-UPLOAD-004', 'placesApi.importMapFile (10 MB)', () => placesApi.importMapFile(7, file()), '/trips/7/places/import/map'], + ['FE-API-UPLOAD-005', 'adminApi.pluginUpload (50 MB)', () => adminApi.pluginUpload(file()), '/admin/plugins/upload'], + ['FE-API-UPLOAD-006', 'journeyApi.uploadCover (20 MB)', () => journeyApi.uploadCover(7, fd()), '/journeys/7/cover'], + ['FE-API-UPLOAD-007', 'journeyApi.uploadPhotos (20 MB)', () => journeyApi.uploadPhotos(7, fd()), '/journeys/entries/7/photos'], + ['FE-API-UPLOAD-008', 'journeyApi.uploadGalleryVideo (500 MB)', () => journeyApi.uploadGalleryVideo(7, fd()), '/journeys/7/gallery/video'], + ['FE-API-UPLOAD-009', 'filesApi.upload (500 MB)', () => filesApi.upload(7, fd()), '/trips/7/files'], + ['FE-API-UPLOAD-010', 'collabApi.uploadNoteFile (50 MB)', () => collabApi.uploadNoteFile(7, 3, fd()), '/trips/7/collab/notes/3/files'], + ['FE-API-UPLOAD-011', 'backupApi.uploadRestore (500 MB)', () => backupApi.uploadRestore(file()), '/backup/upload-restore'], + ['FE-API-UPLOAD-012', 'collectionsApi.uploadCover (20 MB)', () => collectionsApi.uploadCover(7, fd()), '/addons/collections/7/cover'], + ] + + for (const [id, desc, invoke, url] of cases) { + it(`${id}: ${desc} posts with timeout 0`, async () => { + const post = spyPost() + await invoke() + expect(post).toHaveBeenCalledWith( + url, + expect.any(FormData), + expect.objectContaining({ timeout: 0 }), + ) + }) + } + + it('FE-API-UPLOAD-013: reservationsApi booking import posts with timeout 0', async () => { + const post = spyPost() + await reservationsApi.importBookingPreview(7, [file()]) + expect(post).toHaveBeenCalledWith( + '/trips/7/reservations/import/booking', + expect.any(FormData), + expect.objectContaining({ timeout: 0 }), + ) + }) +}) diff --git a/client/src/components/Admin/AdminPluginsPanel.test.tsx b/client/src/components/Admin/AdminPluginsPanel.test.tsx new file mode 100644 index 000000000..3264de51c --- /dev/null +++ b/client/src/components/Admin/AdminPluginsPanel.test.tsx @@ -0,0 +1,121 @@ +import { http, HttpResponse } from 'msw' +import { beforeEach, describe, expect, it } from 'vitest' +import { server } from '../../../tests/helpers/msw/server' +import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render' +import { resetAllStores } from '../../../tests/helpers/store' +import AdminPluginsPanel from './AdminPluginsPanel' + +/** + * The "allowed hosts" chip. A plugin that talks to a SELF-HOSTED service (a Gotify) can't + * name the operator's host in its manifest, so the admin adds it — but they'd never know + * that unless the card says so. Until a host exists the plugin can reach NOTHING and looks + * silently broken, which is why the chip is warning-toned and actionable in that state. + */ +function plugin(over: Record = {}) { + return { + id: 'trek-gotify', name: 'Gotify', description: 'Push notifications', type: 'integration', + icon: 'Bell', version: '1.0.0', status: 'active', enabled: 1, + last_error: null, reviewed_at: null, source_repo: null, + permissions: JSON.stringify(['hook:notification-channel', 'http:outbound:gotify.net']), + capabilities: '{}', + operatorEgress: true, + egressHostCount: 0, + dependencyStatus: 'ok', + dependencyIssues: { disabledAddons: [], missing: [], versionMismatch: [] }, + ...over, + } +} + +function mockList(p: Record) { + server.use( + http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [p] })), + http.get('*/api/admin/plugins/registry', () => HttpResponse.json({ plugins: [] })), + ) +} + +beforeEach(() => resetAllStores()) + +describe('AdminPluginsPanel — allowed-hosts chip', () => { + it('FE-COMP-PLUGINS-EGRESS-001: invites the admin to add a host when none is set', async () => { + mockList(plugin({ egressHostCount: 0 })) + render() + // The plugin can't reach anything yet — the card must say so, not stay silent. + expect(await screen.findByRole('button', { name: /add allowed host/i })).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-EGRESS-002: shows the count once hosts exist', async () => { + mockList(plugin({ egressHostCount: 2 })) + render() + expect(await screen.findByRole('button', { name: /2 allowed host/i })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /add allowed host/i })).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-EGRESS-003: a plugin that never declared operatorEgress gets NO chip', async () => { + mockList(plugin({ operatorEgress: false })) + render() + await screen.findByText('Gotify') + // An admin must never be invited to widen egress for a plugin that didn't ask for it. + expect(screen.queryByRole('button', { name: /allowed host/i })).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-EGRESS-004: clicking the chip opens the allowed-hosts dialog', async () => { + mockList(plugin({ egressHostCount: 1 })) + server.use( + http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () => + HttpResponse.json({ supported: true, hosts: ['gotify.mydomain.com'] })), + ) + render() + + fireEvent.click(await screen.findByRole('button', { name: /1 allowed host/i })) + await waitFor(() => expect(screen.getByText('gotify.mydomain.com')).toBeInTheDocument()) + }) +}) + +/** + * The Discover (pre-install) modal. Its "Connects to" list is what a reviewer reads to + * judge a plugin's network reach — so for an operatorEgress plugin that list is NOT the + * whole story, and saying nothing would actively mislead them. + */ +function mockDetail(manifest: Record | null) { + server.use( + http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })), + // pluginBrowse returns the ARRAY itself, not { plugins: [...] }. + http.get('*/api/admin/plugins/registry', () => + HttpResponse.json([{ id: 'trek-gotify', name: 'Gotify', author: 'jubnl', description: 'Push', repo: 'jubnl/trek-gotify', type: 'integration', tags: [] }])), + http.get('*/api/admin/plugins/registry/trek-gotify', () => + HttpResponse.json({ + id: 'trek-gotify', name: 'Gotify', author: 'jubnl', description: 'Push', repo: 'jubnl/trek-gotify', + type: 'integration', tags: [], size: 1024, publishedAt: null, latest: '1.0.0', manifest, + })), + ) +} + +describe('AdminPluginsPanel — Discover modal, operator-egress pill', () => { + const base = { permissions: ['hook:notification-channel', 'http:outbound:gotify.net'], egress: ['gotify.net'], settings: [], license: 'MIT', icon: null } + + /** The panel opens on Installed — switch to Discover, then open the plugin's card. */ + async function openDetail() { + render() + fireEvent.click(await screen.findByRole('button', { name: /discover/i })) + fireEvent.click(await screen.findByText('Gotify')) + } + + it('FE-COMP-PLUGINS-EGRESS-005: warns that the host list is not the whole story', async () => { + mockDetail({ ...base, operatorEgress: true }) + await openDetail() + + // The declared host is still listed… + expect(await screen.findByText('gotify.net')).toBeInTheDocument() + // …alongside the pill saying an admin adds more. + expect(screen.getByText(/hosts you add/i)).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-EGRESS-006: an ordinary plugin gets NO such pill', async () => { + mockDetail({ ...base, operatorEgress: false }) + await openDetail() + + expect(await screen.findByText('gotify.net')).toBeInTheDocument() + // Its egress list IS the whole story — claiming otherwise would be a lie. + expect(screen.queryByText(/hosts you add/i)).not.toBeInTheDocument() + }) +}) diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index 6c021e331..00d8b4af9 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -1,12 +1,13 @@ import { useEffect, useMemo, useRef, useState, type DragEvent } from 'react' import { Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud, - ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, + ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard, Radio, Luggage, Plane, Globe, Image, CalendarDays, Map, Bell, Cloud, Camera, Compass, BookOpen, Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, } from 'lucide-react' import { adminApi } from '../../api/client' +import { usePluginStore } from '../../store/pluginStore' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import ConfirmDialog from '../shared/ConfirmDialog' @@ -41,6 +42,10 @@ interface PluginRow { source_repo: string | null permissions: string capabilities: string + /** The plugin needs OPERATOR-supplied egress hosts (a self-hosted target). */ + operatorEgress?: boolean + /** How many hosts the admin has added — 0 means the plugin can't reach anything yet. */ + egressHostCount?: number dependencies?: PluginDependencies dependencyStatus?: DependencyStatus dependencyIssues?: DependencyIssues @@ -56,6 +61,7 @@ interface RegistryItem { latest: string | null minTrekVersion: string | null reviewedAt: string | null + downloadCount?: number | null screenshotUrl: string | null requiredAddons?: string[] pluginDependencies?: PluginDep[] @@ -66,6 +72,8 @@ interface RegistryDetail extends RegistryItem { manifest: { permissions: string[] egress: string[] + /** The plugin needs OPERATOR-supplied hosts — its egress list is not the whole story. */ + operatorEgress?: boolean settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }> license: string | null icon: string | null @@ -85,7 +93,7 @@ interface ActivateErr { type T = (k: string, p?: Record) => string type TypeFilter = 'all' | 'widget' | 'page' | 'integration' | 'trip-page' type StatusFilter = 'all' | 'on' | 'off' | 'update' | 'err' -type SortKey = 'name' | 'recent' | 'updates' +type SortKey = 'name' | 'recent' | 'updates' | 'downloads' // Runtime health → dot colour on the icon tile. const HEALTH: Record = { @@ -104,12 +112,19 @@ const ICON_MAP: Record // Turn a plugin's declared permissions + capabilities into the at-a-glance chips // that make its real reach legible without opening the detail dialog. -function deriveCaps(perms: string[], caps: { widget?: { slot?: string } }, t: T): Cap[] { +function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPage?: { replaces?: string[] } }, t: T): Cap[] { const out: Cap[] = [] if (perms.includes('db:read:trips')) out.push({ icon: Database, label: t('admin.plugins.cap.readsTrips') }) if (perms.includes('db:read:users')) out.push({ icon: Users, label: t('admin.plugins.cap.readsUsers') }) @@ -148,14 +163,19 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string } }, t: T) if (caps.widget) { const slotKey = caps.widget.slot === 'hero' ? 'admin.plugins.cap.heroWidget' : caps.widget.slot === 'place-detail' ? 'admin.plugins.cap.placeSlot' + : caps.widget.slot === 'day-detail' ? 'admin.plugins.cap.daySlot' + : caps.widget.slot === 'reservation-detail' ? 'admin.plugins.cap.reservationSlot' : 'admin.plugins.cap.widget' out.push({ icon: LayoutDashboard, label: t(slotKey as never) }) } + // Replacing planner tabs is the one capability that HIDES core UI — always chip it. + if (caps.tripPage?.replaces?.length) out.push({ icon: LayoutDashboard, label: t('admin.plugins.cap.replacesTabs') }) if (perms.some(p => p.startsWith('ws:broadcast'))) out.push({ icon: Radio, label: t('admin.plugins.cap.realtime') }) if (perms.includes('hook:photo-provider')) out.push({ icon: Image, label: t('admin.plugins.cap.photos') }) if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') }) if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') }) if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') }) + if (perms.includes('hook:notification-channel')) out.push({ icon: Bell, label: t('admin.plugins.cap.notificationChannel') }) if (perms.includes('events:subscribe')) out.push({ icon: Radio, label: t('admin.plugins.cap.events') }) for (const h of perms.filter(p => p.startsWith('http:outbound:')).map(p => p.slice('http:outbound:'.length)).filter(Boolean)) { out.push({ icon: ArrowRight, label: h, net: true }) @@ -204,6 +224,16 @@ function SideloadedBadge({ t }: { t: T }) { ) } +/** Marks a dev-linked plugin: loaded from a local build dir + hot-reloaded (dev only). */ +function DevLinkBadge({ t }: { t: T }) { + return ( + + {t('admin.plugins.devLinkBadge')} + + ) +} + function TypeBadge({ type, t }: { type: string; t: T }) { return ( @@ -216,6 +246,8 @@ export default function AdminPluginsPanel() { const { t, locale } = useTranslation() const toast = useToast() const [runtimeOn, setRuntimeOn] = useState(false) + const [devLink, setDevLink] = useState(false) // dev-link enabled server-side (TREK_PLUGINS_DEV_LINK) + const [linkPath, setLinkPath] = useState('') const [plugins, setPlugins] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(false) @@ -225,6 +257,10 @@ export default function AdminPluginsPanel() { const [latest, setLatest] = useState>({}) const [detailFor, setDetailFor] = useState(null) const [errorsFor, setErrorsFor] = useState<{ id: string; rows: Array<{ ts: string; level: string; message: string }> } | null>(null) + const [egressFor, setEgressFor] = useState<{ id: string; supported: boolean; hosts: string[] } | null>(null) + const [egressDraft, setEgressDraft] = useState('') + const [egressSaving, setEgressSaving] = useState(false) + const [egressError, setEgressError] = useState('') const [confirmUninstall, setConfirmUninstall] = useState(null) // A QUEUE, not one slot: "Update All" can produce several re-consent prompts — // each must be shown, not silently overwritten by the last one. @@ -239,15 +275,27 @@ export default function AdminPluginsPanel() { const [statusFilter, setStatusFilter] = useState('all') const [sort, setSort] = useState('name') + // 'updates' only ranks installed plugins, 'downloads' only the registry — snap + // the key back to name when switching tabs so the dropdown never carries a label + // for an option the active tab can't offer. + useEffect(() => { + if (view === 'discover' && sort === 'updates') setSort('name') + else if (view === 'installed' && sort === 'downloads') setSort('name') + }, [view, sort]) + // Sideload upload: drag a plugin .zip onto the panel or use the toolbar button. const [dragActive, setDragActive] = useState(false) const uploadInputRef = useRef(null) const dragDepth = useRef(0) const refresh = () => { + // Keep the app-wide active-plugin store in sync so widget/hero/tab consumers + // (e.g. the dashboard) reflect an activate/deactivate without a full reload (F5). + void usePluginStore.getState().loadPlugins() adminApi.plugins() - .then((d: { enabled: boolean; plugins: PluginRow[] }) => { + .then((d: { enabled: boolean; devLink?: boolean; plugins: PluginRow[] }) => { setRuntimeOn(!!d.enabled) + setDevLink(!!d.devLink) setPlugins(d.plugins || []) if ((d.plugins || []).length) { adminApi.pluginBrowse() @@ -312,6 +360,31 @@ export default function AdminPluginsPanel() { const f = e.dataTransfer.files?.[0] if (f) void uploadPlugin(f) } + const openEgress = (id: string) => { + setMenu(null) + setEgressDraft(''); setEgressError('') + adminApi.pluginEgressHosts(id) + .then(d => setEgressFor({ id, supported: d.supported, hosts: d.hosts })) + .catch(() => setEgressFor({ id, supported: false, hosts: [] })) + } + + // Saving RE-SPAWNS the plugin: the child's egress guard is installed once at init and + // a second init is refused, so a live child's allow-list can never be widened in place. + const saveEgress = async (hosts: string[]) => { + if (!egressFor) return + setEgressSaving(true); setEgressError('') + try { + const d = await adminApi.pluginSetEgressHosts(egressFor.id, hosts) + setEgressFor({ ...egressFor, hosts: d.hosts }) + setEgressDraft('') + } catch (e) { + const err = e as { response?: { data?: { error?: string } } } + setEgressError(err.response?.data?.error || t('common.error')) + } finally { + setEgressSaving(false) + } + } + const openErrors = (id: string) => { setMenu(null) adminApi.pluginErrors(id) @@ -322,6 +395,23 @@ export default function AdminPluginsPanel() { const updateAvailable = (p: PluginRow) => !!(p.version && latest[p.id] && isNewer(latest[p.id], p.version)) const install = (id: string) => act(id, () => adminApi.pluginInstall(id), t('admin.plugins.installed')) const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted')) + // Dev-link: register a plugin from a local built directory (dev only). Reuses the + // same busy/toast/refresh loop as uploadPlugin; the server gates it. + const linkLocal = async () => { + const p = linkPath.trim() + if (!p) return + setBusy('__link'); setMenu(null) + try { + const res = await adminApi.pluginLink(p) + setView('installed') + setLinkPath('') + toast.success(t('admin.plugins.devLinkLinked', { id: res.id })) + } catch (e) { + toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError')) + } finally { + setBusy(null); refresh() + } + } const installedIds = new Set(plugins.map(p => p.id)) // Installed-but-disabled direct deps that enabling `p` will auto-enable first. @@ -431,9 +521,13 @@ export default function AdminPluginsPanel() { const matchesType = typeFilter === 'all' || r.type === typeFilter return matchesText && matchesType }) - items = [...items].sort((a, b) => a.name.localeCompare(b.name)) + items = [...items].sort((a, b) => { + if (sort === 'downloads') return (b.downloadCount ?? 0) - (a.downloadCount ?? 0) || a.name.localeCompare(b.name) + if (sort === 'recent') return (Date.parse(b.reviewedAt ?? '') || 0) - (Date.parse(a.reviewedAt ?? '') || 0) || a.name.localeCompare(b.name) + return a.name.localeCompare(b.name) + }) return items - }, [registry, q, typeFilter]) + }, [registry, q, typeFilter, sort]) const anyFilter = q.trim() !== '' || typeFilter !== 'all' || statusFilter !== 'all' @@ -478,6 +572,24 @@ export default function AdminPluginsPanel() {
)} + {/* Dev-link: register + hot-reload a plugin from a local build dir (dev only). */} + {devLink && runtimeOn && !loading && !error && ( +
{ e.preventDefault(); void linkLocal() }} + className="mx-4 sm:mx-6 mt-4 p-3 rounded-xl border border-accent/30 bg-accent-soft flex flex-col sm:flex-row sm:items-center gap-2.5"> +
+ + {t('admin.plugins.devLinkTitle')} +
+ setLinkPath(e.target.value)} spellCheck={false} + placeholder={t('admin.plugins.devLinkPathPlaceholder')} + className="flex-1 min-w-0 h-[34px] px-2.5 rounded-lg border border-edge bg-surface-card text-sm text-content placeholder:text-content-faint focus:outline-none focus:border-accent" /> + +
+ )} + {/* Toolbar — on mobile: tabs+rescan, then full-width search, then a right-aligned filter row. On sm+ the wrappers collapse (sm:contents) so everything sits in one wrapping row. */} @@ -530,7 +642,9 @@ export default function AdminPluginsPanel() { )} } - options={[['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['updates', t('admin.plugins.sortUpdates')]]} + options={view === 'discover' + ? [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['downloads', t('admin.plugins.sortDownloads')]] + : [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['updates', t('admin.plugins.sortUpdates')]]} valueLabel={sortLabel(sort, t)} onPick={v => setSort(v as SortKey)} /> @@ -579,7 +693,8 @@ export default function AdminPluginsPanel() { hasUpdate={updateAvailable(p)} latestVer={latest[p.id]} onToggle={() => toggle(p)} onUpdate={() => runUpdate(p)} onRestart={() => restart(p.id)} - onErrors={() => openErrors(p.id)} onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} /> + onErrors={() => openErrors(p.id)} onEgress={() => openEgress(p.id)} + onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} /> ))} )} @@ -615,6 +730,56 @@ export default function AdminPluginsPanel() { )} + {/* Operator-supplied egress hosts */} + {egressFor && ( +
setEgressFor(null)}> +
e.stopPropagation()}> +
+ {egressFor.id} — {t('admin.plugins.allowedHosts')} + +
+
+ {!egressFor.supported ? ( +

{t('admin.plugins.allowedHosts.unsupported')}

+ ) : ( + <> +

{t('admin.plugins.allowedHosts.hint')}

+ {egressFor.hosts.length === 0 && ( +

{t('admin.plugins.allowedHosts.none')}

+ )} + {egressFor.hosts.map(h => ( +
+ {h} + +
+ ))} +
+ setEgressDraft(e.target.value)} + placeholder="gotify.example.com" + className="flex-1 rounded-lg border border-edge bg-surface px-3 py-2 text-sm text-content" + /> + +
+ {egressError &&

{egressError}

} +

{t('admin.plugins.allowedHosts.restartNote')}

+ + )} +
+
+
+ )} + setConfirmUninstall(null)} @@ -654,7 +819,10 @@ function statusLabel(s: StatusFilter, t: T): string { : s === 'off' ? t('admin.plugins.stateOff') : s === 'update' ? t('admin.plugins.filterUpdate') : t('admin.plugins.status.error') } function sortLabel(s: SortKey, t: T): string { - return s === 'name' ? t('admin.plugins.sortName') : s === 'recent' ? t('admin.plugins.sortRecent') : t('admin.plugins.sortUpdates') + return s === 'name' ? t('admin.plugins.sortName') + : s === 'recent' ? t('admin.plugins.sortRecent') + : s === 'downloads' ? t('admin.plugins.sortDownloads') + : t('admin.plugins.sortUpdates') } function SegBtn({ active, onClick, label, count }: { active: boolean; onClick: () => void; label: string; count?: number }) { @@ -718,10 +886,10 @@ function EmptyState({ t, onDiscover }: { t: T; onDiscover: () => void }) { ) } -function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggle, onUpdate, onRestart, onErrors, onUninstall }: { +function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggle, onUpdate, onRestart, onErrors, onEgress, onUninstall }: { p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void hasUpdate: boolean; latestVer?: string - onToggle: () => void; onUpdate: () => void; onRestart: () => void; onErrors: () => void; onUninstall: () => void + onToggle: () => void; onUpdate: () => void; onRestart: () => void; onErrors: () => void; onEgress: () => void; onUninstall: () => void }) { const caps = deriveCaps(parseJson(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t) const deps = deriveDeps(p, t) @@ -742,13 +910,14 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl {p.version && v{p.version}} {p.reviewed_at && } {p.source_repo === 'local:upload' && } + {p.source_repo === 'local:link' && } {p.description &&

{p.description}

} {p.status === 'error' && p.last_error ? (
{p.last_error}
- ) : caps.length > 0 && ( + ) : (caps.length > 0 || p.operatorEgress) && (
{caps.map((c, i) => ( {c.label} ))} + {/* This plugin talks to a service only the OPERATOR can name (a self-hosted + Gotify/ntfy), so its manifest can't list the host — the admin adds it. + Actionable, and warning-toned until at least one host exists, because + until then the plugin cannot reach anything and looks silently broken. */} + {p.operatorEgress && ( + + )}
)} {deps.length > 0 && ( @@ -792,11 +980,18 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl } label={t('admin.plugins.restart')} onClick={onRestart} /> )} } label={t('admin.plugins.viewErrors')} onClick={onErrors} /> - {p.source_repo && p.source_repo !== 'local:upload' && ( - setMenu(null)} - className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-tertiary transition-colors"> - {t('admin.plugins.sourceRepo')} - + } label={t('admin.plugins.allowedHosts')} onClick={onEgress} /> + {p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && ( + <> + setMenu(null)} + className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-tertiary transition-colors"> + {t('admin.plugins.sourceRepo')} + + setMenu(null)} + className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-tertiary transition-colors"> + {t('admin.plugins.reportIssue')} + + )}
} label={t('common.delete')} danger onClick={onUninstall} /> @@ -876,6 +1071,11 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
{item.latest && v{item.latest}} + {typeof item.downloadCount === 'number' && item.downloadCount > 0 && ( + + {formatCompactCount(item.downloadCount)} + + )}
)} - {manifest && manifest.egress.length > 0 && ( + {manifest && (manifest.egress.length > 0 || manifest.operatorEgress) && (

{t('admin.plugins.connectsTitle')}

-
+
{manifest.egress.map(h => ( {h} ))} + {/* The hosts above are NOT the whole story for this plugin: it talks to a + service only the operator can name, so its reach is whatever an admin + adds after install. Say so HERE — this is the pre-install review, and a + reviewer who reads only the host list would otherwise be misled. */} + {manifest.operatorEgress && ( + + {t('admin.plugins.operatorEgressPill')} + + )}
+ {manifest.operatorEgress && ( +

{t('admin.plugins.operatorEgressHint')}

+ )}
)} @@ -1002,6 +1222,9 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local {sizeKb && } {item.minTrekVersion && } {item.reviewedAt && } + {typeof item.downloadCount === 'number' && item.downloadCount > 0 && ( + + )}
@@ -1011,6 +1234,10 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg border border-edge bg-surface-card text-content-secondary hover:text-content hover:border-content-faint transition-colors"> {t('admin.plugins.sourceRepo')} + + {t('admin.plugins.reportIssue')} + {homepage && ( diff --git a/client/src/components/Admin/DefaultUserSettingsTab.tsx b/client/src/components/Admin/DefaultUserSettingsTab.tsx index f61dfec76..c9121edec 100644 --- a/client/src/components/Admin/DefaultUserSettingsTab.tsx +++ b/client/src/components/Admin/DefaultUserSettingsTab.tsx @@ -6,7 +6,7 @@ import { useToast } from '../shared/Toast' import Section from '../Settings/Section' import CustomSelect from '../shared/CustomSelect' import { MapView } from '../Map/MapView' -import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants' +import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants' import type { DistanceUnit, Place } from '../../types' import { MAPBOX_DEFAULT_STYLE, @@ -286,7 +286,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement { onChange={(value: string) => { if (value) save({ default_currency: value }) }} placeholder={t('settings.currency')} searchable - options={CURRENCIES.map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))} + options={currenciesWith(defaults.default_currency).map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))} size="sm" style={{ maxWidth: 240 }} /> diff --git a/client/src/components/Budget/BudgetPanel.constants.ts b/client/src/components/Budget/BudgetPanel.constants.ts index d6bd5d7af..a4a78bb48 100644 --- a/client/src/components/Budget/BudgetPanel.constants.ts +++ b/client/src/components/Budget/BudgetPanel.constants.ts @@ -1,23 +1,72 @@ +// The full set of currencies the Frankfurter v2 FX API supports (archived codes +// excluded), so every selectable currency actually converts. Regenerate from +// `GET https://api.frankfurter.dev/v2/currencies?expand=providers` (iso_code + +// symbol) if the provider's list changes. See issue #1470. export const CURRENCIES = [ - 'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', - 'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR', - 'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS', - 'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'KGS', - 'BDT', 'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS', + 'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', + 'BAM', 'BBD', 'BDT', 'BHD', 'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD', + 'BTN', 'BWP', 'BYN', 'BZD', 'CAD', 'CDF', 'CHF', 'CLP', 'CNH', 'CNY', + 'COP', 'CRC', 'CUP', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP', + 'ERN', 'ETB', 'EUR', 'FJD', 'FKP', 'GBP', 'GEL', 'GGP', 'GHS', 'GIP', + 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HTG', 'HUF', 'IDR', 'ILS', + 'IMP', 'INR', 'IQD', 'IRR', 'ISK', 'JEP', 'JMD', 'JOD', 'JPY', 'KES', + 'KGS', 'KHR', 'KMF', 'KPW', 'KRW', 'KWD', 'KYD', 'KZT', 'LAK', 'LBP', + 'LKR', 'LRD', 'LSL', 'LYD', 'MAD', 'MDL', 'MGA', 'MKD', 'MMK', 'MNT', + 'MOP', 'MRO', 'MRU', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD', + 'NGN', 'NIO', 'NOK', 'NPR', 'NZD', 'OMR', 'PAB', 'PEN', 'PGK', 'PHP', + 'PKR', 'PLN', 'PYG', 'QAR', 'RON', 'RSD', 'RUB', 'RWF', 'SAR', 'SBD', + 'SCR', 'SDG', 'SEK', 'SGD', 'SHP', 'SLE', 'SOS', 'SRD', 'SSP', 'STN', + 'SVC', 'SYP', 'SZL', 'THB', 'TJS', 'TMT', 'TND', 'TOP', 'TRY', 'TTD', + 'TWD', 'TZS', 'UAH', 'UGX', 'USD', 'UYU', 'UZS', 'VES', 'VND', 'VUV', + 'WST', 'XAF', 'XAG', 'XAU', 'XCD', 'XCG', 'XDR', 'XOF', 'XPD', 'XPF', + 'XPT', 'YER', 'ZAR', 'ZMW', 'ZWG', ] export const SYMBOLS: Record = { - EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł', - SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$', - NZD: 'NZ$', BRL: 'R$', MXN: 'MX$', INR: '₹', IDR: 'Rp', MYR: 'RM', - PHP: '₱', SGD: 'S$', KRW: '₩', CNY: '¥', HKD: 'HK$', TWD: 'NT$', - ZAR: 'R', AED: 'د.إ', SAR: '﷼', ILS: '₪', EGP: 'E£', MAD: 'MAD', - HUF: 'Ft', RON: 'lei', BGN: 'лв', HRK: 'kn', ISK: 'kr', RUB: '₽', - UAH: '₴', KGS: 'сом', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', - COP: 'CO$', PEN: 'S/.', ARS: 'AR$', + AED: 'د.إ', AFN: '؋', ALL: 'L', AMD: '֏', ANG: 'ƒ', + AOA: 'Kz', ARS: '$', AUD: '$', AWG: 'ƒ', AZN: '₼', + BAM: 'КМ', BBD: '$', BDT: '৳', BHD: 'د.ب', BIF: 'Fr', + BMD: '$', BND: '$', BOB: 'Bs.', BRL: 'R$', BSD: '$', + BTN: 'Nu.', BWP: 'P', BYN: 'Br', BZD: '$', CAD: '$', + CDF: 'Fr', CHF: 'CHF', CLP: '$', CNH: '¥', CNY: '¥', + COP: '$', CRC: '₡', CUP: '$', CVE: '$', CZK: 'Kč', + DJF: 'Fdj', DKK: 'kr.', DOP: '$', DZD: 'د.ج', EGP: 'ج.م', + ERN: 'Nfk', ETB: 'Br', EUR: '€', FJD: '$', FKP: '£', + GBP: '£', GEL: '₾', GGP: '£', GHS: '₵', GIP: '£', + GMD: 'D', GNF: 'Fr', GTQ: 'Q', GYD: '$', HKD: '$', + HNL: 'L', HTG: 'G', HUF: 'Ft', IDR: 'Rp', ILS: '₪', + IMP: '£', INR: '₹', IQD: 'ع.د', IRR: '﷼', ISK: 'kr.', + JEP: '£', JMD: '$', JOD: 'د.ا', JPY: '¥', KES: 'KSh', + KGS: 'som', KHR: '៛', KMF: 'Fr', KPW: '₩', KRW: '₩', + KWD: 'د.ك', KYD: '$', KZT: '₸', LAK: '₭', LBP: 'ل.ل', + LKR: '₨', LRD: '$', LSL: 'L', LYD: 'ل.د', MAD: 'د.م.', + MDL: 'L', MGA: 'Ar', MKD: 'ден', MMK: 'K', MNT: '₮', + MOP: 'P', MRO: 'UM', MRU: 'UM', MUR: '₨', MVR: 'MVR', + MWK: 'MK', MXN: '$', MYR: 'RM', MZN: 'MTn', NAD: '$', + NGN: '₦', NIO: 'C$', NOK: 'kr', NPR: 'Rs.', NZD: '$', + OMR: 'ر.ع.', PAB: 'B/.', PEN: 'S/', PGK: 'K', PHP: '₱', + PKR: '₨', PLN: 'zł', PYG: '₲', QAR: 'ر.ق', RON: 'Lei', + RSD: 'RSD', RUB: '₽', RWF: 'FRw', SAR: 'ر.س', SBD: '$', + SCR: '₨', SDG: '£', SEK: 'kr', SGD: '$', SHP: '£', + SLE: 'Le', SOS: 'Sh', SRD: '$', SSP: '£', STN: 'Db', + SVC: '₡', SYP: '£S', SZL: 'E', THB: '฿', TJS: 'ЅМ', + TMT: 'm', TND: 'د.ت', TOP: 'T$', TRY: '₺', TTD: '$', + TWD: '$', TZS: 'Sh', UAH: '₴', UGX: 'USh', USD: '$', + UYU: '$U', UZS: 'so\'m', VES: 'Bs', VND: '₫', VUV: 'Vt', + WST: 'T', XAF: 'CFA', XAG: 'oz t', XAU: 'oz t', XCD: '$', + XCG: 'Cg', XDR: 'SDR', XOF: 'Fr', XPD: 'oz t', XPF: 'Fr', + XPT: 'oz t', YER: '﷼', ZAR: 'R', ZMW: 'K', ZWG: 'ZiG', } -export const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7'] +// Keep a currency the user already saved selectable even after it leaves the +// supported set (e.g. archived BGN/HRK), so opening an existing item or settings +// row doesn't silently blank the field and wipe the value on the next save. +export function currenciesWith(current?: string | null): readonly string[] { + const cur = (current || '').toUpperCase() + return cur && !CURRENCIES.includes(cur) ? [...CURRENCIES, cur] : CURRENCIES +} + +export const PIE_COLORS =['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7'] export const SPLIT_COLORS = [ { solid: '#6366f1', gradient: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }, diff --git a/client/src/components/Budget/BudgetPanel.helpers.test.ts b/client/src/components/Budget/BudgetPanel.helpers.test.ts new file mode 100644 index 000000000..438affa14 --- /dev/null +++ b/client/src/components/Budget/BudgetPanel.helpers.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' +import { calcPP, hasCustomMemberSplit } from './BudgetPanel.helpers' + +describe('BudgetPanel.helpers', () => { + describe('hasCustomMemberSplit (#1458)', () => { + it('is false when no members', () => { + expect(hasCustomMemberSplit({})).toBe(false) + expect(hasCustomMemberSplit({ members: [] })).toBe(false) + }) + + it('is false for an equal split (members carry no amount)', () => { + expect(hasCustomMemberSplit({ members: [{ amount: null }, { amount: null }] })).toBe(false) + expect(hasCustomMemberSplit({ members: [{}, {}] })).toBe(false) + }) + + it('is true as soon as any member has a custom amount', () => { + expect(hasCustomMemberSplit({ members: [{ amount: 90 }, { amount: 10 }] })).toBe(true) + expect(hasCustomMemberSplit({ members: [{ amount: null }, { amount: 10 }] })).toBe(true) + expect(hasCustomMemberSplit({ members: [{ amount: 0 }] })).toBe(true) + }) + }) + + it('calcPP still averages the total for equal splits', () => { + expect(calcPP(100, 2)).toBe(50) + expect(calcPP(100, 0)).toBeNull() + expect(calcPP(100, null)).toBeNull() + }) +}) diff --git a/client/src/components/Budget/BudgetPanel.helpers.ts b/client/src/components/Budget/BudgetPanel.helpers.ts index 855c3e3b0..c3b13fdc9 100644 --- a/client/src/components/Budget/BudgetPanel.helpers.ts +++ b/client/src/components/Budget/BudgetPanel.helpers.ts @@ -64,6 +64,12 @@ export const calcPP = (p: NumOrNull, n: NumOrNull) => (n! > 0 ? (p as number) / export const calcPD = (p: NumOrNull, d: NumOrNull) => (d! > 0 ? (p as number) / (d as number) : null) export const calcPPD = (p: NumOrNull, n: NumOrNull, d: NumOrNull) => (n! > 0 && d! > 0 ? (p as number) / ((n as number) * (d as number)) : null) +// A custom (uneven) split has no single "per person" figure — one member's share +// differs from another's — so the averaged per-person columns are meaningless for it +// (the per-member amounts are shown via the member chips instead). #1458 +export const hasCustomMemberSplit = (item: { members?: { amount?: number | null }[] }) => + (item.members || []).some(m => m.amount != null) + export function splitColorFor(userId: number, order: number) { return SPLIT_COLORS[order % SPLIT_COLORS.length] } diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx index 7ef1d3f24..8876ad4eb 100644 --- a/client/src/components/Budget/BudgetPanel.tsx +++ b/client/src/components/Budget/BudgetPanel.tsx @@ -1,6 +1,6 @@ import { Plus, Calculator, Download } from 'lucide-react' import CustomSelect from '../shared/CustomSelect' -import { CURRENCIES, SYMBOLS } from './BudgetPanel.constants' +import { currenciesWith, SYMBOLS } from './BudgetPanel.constants' import { useBudgetPanel } from './useBudgetPanel' import type { TripMember } from './BudgetPanelMemberChips' import BudgetCategoryTable from './BudgetPanelCategoryTable' @@ -74,7 +74,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro value={currency} onChange={setCurrency} disabled={!canEdit} - options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))} + options={currenciesWith(currency).map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))} searchable /> diff --git a/client/src/components/Budget/BudgetPanelCategoryTable.tsx b/client/src/components/Budget/BudgetPanelCategoryTable.tsx index 941841df0..cd172325d 100644 --- a/client/src/components/Budget/BudgetPanelCategoryTable.tsx +++ b/client/src/components/Budget/BudgetPanelCategoryTable.tsx @@ -1,9 +1,10 @@ -import type { CSSProperties, Dispatch, SetStateAction } from 'react' +import { Fragment, type CSSProperties, type Dispatch, type SetStateAction } from 'react' import { Trash2, Pencil, GripVertical } from 'lucide-react' import type { BudgetItem } from '../../types' +import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions' import { currencyDecimals } from '../../utils/formatters' import { CustomDatePicker } from '../shared/CustomDateTimePicker' -import { calcPP, calcPD, calcPPD } from './BudgetPanel.helpers' +import { calcPP, calcPD, calcPPD, hasCustomMemberSplit } from './BudgetPanel.helpers' import InlineEditCell from './BudgetPanelInlineEditCell' import AddItemRow from './BudgetPanelAddItemRow' import BudgetMemberChips, { type TripMember } from './BudgetPanelMemberChips' @@ -53,6 +54,7 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleUpdateField, handleAddItem, tripId, currency, locale, t, fmt, hasMultipleMembers, tripMembers, setBudgetItemMembers, toggleBudgetMemberPaid, th, td }: BudgetCategoryTableProps) { const items = grouped.get(cat) || [] + const contribFor = usePluginViewContributions('costs', tripId) const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0) const color = categoryColor(cat) return ( @@ -149,12 +151,17 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd {items.map(item => { - const pp = calcPP(item.total_price, item.persons) + // A custom (uneven) split has no single per-person figure — the per-member + // amounts are shown via the member chips — so blank those columns (#1458). + const customSplit = hasCustomMemberSplit(item) + const pp = customSplit ? null : calcPP(item.total_price, item.persons) const pd = calcPD(item.total_price, item.days) - const ppd = calcPPD(item.total_price, item.persons, item.days) + const ppd = customSplit ? null : calcPPD(item.total_price, item.persons, item.days) const hasMembers = (item.members?.length ?? 0) > 0 + const contributions = contribFor(item.id) return ( - + + {contributions.length > 0 && ( + + + + + + )} + ) })} {canEdit && handleAddItem(cat, data)} t={t} />} diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index 7899e2b83..634b0384f 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -222,6 +222,34 @@ describe('CostsPanel — settlements in the ledger', () => { expect(posted!.payers).toEqual([]) }) + it('exports the expenses as a CSV download (#1500)', async () => { + // Display in the trip's own currency so FX conversion is an identity. + seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } }) + let exported: Blob | null = null + const createObjURL = vi.spyOn(URL, 'createObjectURL').mockImplementation(b => { exported = b as Blob; return 'blob:mock' }) + const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner; tapas' }), total_price: 90, expense_date: '2025-06-15' } + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })), + http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })), + ) + const { default: userEvent } = await import('@testing-library/user-event') + const user = userEvent.setup() + render() + + await screen.findByText('Dinner; tapas') + await user.click(screen.getByTitle('Export CSV')) + + expect(exported).toBeTruthy() + const text = await exported!.text() + expect(text).toContain('Date;Name;Category;Amount;Currency;Amount (EUR);Note') + expect(text).toContain('"Dinner; tapas"') // separator inside the name gets quoted + expect(text).toContain('Food & drink') // category label, not the raw key + expect(text).toContain('90.00;EUR') + createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore() + }) + it('supports itemized receipt ticket manual entry and split assignment', async () => { let posted: Record | null = null server.use( diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index d5e622ccb..08caee73a 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo, useCallback } from 'react' import { useSearchParams } from 'react-router-dom' -import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle } from 'lucide-react' +import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download } from 'lucide-react' import { useTripStore } from '../../store/tripStore' import { useAuthStore } from '../../store/authStore' import { useSettingsStore } from '../../store/settingsStore' @@ -14,11 +14,12 @@ import { formatMoney, currencyDecimals, currencyLocale } from '../../utils/forma import Modal from '../shared/Modal' import CustomSelect from '../shared/CustomSelect' import { CustomDatePicker } from '../shared/CustomDateTimePicker' -import { SYMBOLS, CURRENCIES, SPLIT_COLORS } from './BudgetPanel.constants' +import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants' import { COST_CATEGORY_LIST, catMeta } from './costsCategories' import type { BudgetItem } from '../../types' import type { TripMember } from './BudgetPanelMemberChips' import GuestBadge from '../shared/GuestBadge' +import { NumericInput } from '../shared/NumericInput' export function splitEqualShares(total: number, members: { user_id: number }[], itemId: number): Record { const n = members.length @@ -289,6 +290,39 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps try { await deleteBudgetItem(tripId, id); loadSettlement() } catch { toast.error(t('common.unknownError')) } } + // CSV export of all expenses — the wiki-documented export that got lost in the + // Costs rework (#1500). One row per expense, oldest first. + const handleExportCsv = () => { + const sep = ';' + const esc = (v: unknown) => { const s = String(v ?? ''); return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s } + const fmtDate = (iso: string) => { if (!iso) return ''; try { return new Date(iso + 'T00:00:00Z').toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) } catch { return iso } } + + const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', 'Amount (' + base + ')', 'Note'] + const rows = [header.join(sep)] + const items = budgetItems.slice().sort((a, b) => (a.expense_date || '').localeCompare(b.expense_date || '')) + for (const e of items) { + const cur = curOf(e) + // Ticket notes carry the itemized-receipt JSON, not a human note. + const note = e.note && !e.note.startsWith('TICKETJSON:') ? e.note : '' + rows.push([ + esc(fmtDate(e.expense_date || '')), esc(e.name), esc(t(catMeta(e.category).labelKey)), + (e.total_price || 0).toFixed(currencyDecimals(cur)), cur, + baseTotal(e).toFixed(currencyDecimals(base)), + esc(note), + ].join(sep)) + } + + const bom = '' + const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9À-ɏ _-]/g, '').trim() + a.download = `costs-${safeName}.csv` + a.click() + URL.revokeObjectURL(url) + } + // ── small presentational helpers ──────────────────────────────────────── const Avatar = ({ id, size = 24 }: { id: number; size?: number }) => { const url = personById(id)?.avatar_url @@ -419,6 +453,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps ))} + @@ -618,7 +657,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps {/* Expenses */}
-
{t('costs.expenses')}
+
+
{t('costs.expenses')}
+ +
setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')} className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 'calc(14px * var(--fs-scale-body, 1))', width: '100%', fontFamily: 'inherit' }} /> @@ -1151,8 +1197,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{sym(currency)} - onTotalChange(e.target.value)} +
@@ -1161,7 +1207,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
setCurrency(String(v))} searchable - options={CURRENCIES.map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))} + options={currenciesWith(currency).map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))} style={{ width: '100%' }} />
@@ -1244,12 +1290,11 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo />
{sym(currency)} - handleUpdateItemPrice(item.id, e.target.value)} + onValueChange={v => handleUpdateItemPrice(item.id, v)} className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 13, fontWeight: 600, textAlign: 'right', padding: '6px 0' }} /> diff --git a/client/src/components/Budget/useBudgetPanel.ts b/client/src/components/Budget/useBudgetPanel.ts index 442bf037c..e8105d573 100644 --- a/client/src/components/Budget/useBudgetPanel.ts +++ b/client/src/components/Budget/useBudgetPanel.ts @@ -7,7 +7,7 @@ import { useTranslation } from '../../i18n' import { budgetApi } from '../../api/client' import type { BudgetItem } from '../../types' import { currencyDecimals } from '../../utils/formatters' -import { widgetTheme, fmtNum, calcPP, calcPD, calcPPD } from './BudgetPanel.helpers' +import { widgetTheme, fmtNum, calcPP, calcPD, calcPPD, hasCustomMemberSplit } from './BudgetPanel.helpers' import { PIE_COLORS } from './BudgetPanel.constants' import type { TripMember } from './BudgetPanelMemberChips' @@ -167,9 +167,11 @@ export function useBudgetPanel(tripId: number, tripMembers: TripMember[]) { for (const cat of categoryNames) { for (const item of (grouped.get(cat) || [])) { - const pp = calcPP(item.total_price, item.persons) + // A custom (uneven) split has no single per-person figure, so leave those columns blank (#1458). + const customSplit = hasCustomMemberSplit(item) + const pp = customSplit ? null : calcPP(item.total_price, item.persons) const pd = calcPD(item.total_price, item.days) - const ppd = calcPPD(item.total_price, item.persons, item.days) + const ppd = customSplit ? null : calcPPD(item.total_price, item.persons, item.days) rows.push([ esc(item.category), esc(item.name), esc(fmtDate(item.expense_date || '')), fmtPrice(item.total_price), item.persons ?? '', item.days ?? '', diff --git a/client/src/components/Collections/CollectionMapPanel.tsx b/client/src/components/Collections/CollectionMapPanel.tsx index 88f7eb817..718f09de5 100644 --- a/client/src/components/Collections/CollectionMapPanel.tsx +++ b/client/src/components/Collections/CollectionMapPanel.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { PanelLeftClose, PanelLeftOpen, Search, Plus } from 'lucide-react' +import { PanelLeftClose, PanelLeftOpen, Search } from 'lucide-react' import type { CollectionPlace } from '@trek/shared' import type { TranslationFn } from '../../types' import CollectionMap from './CollectionMap' @@ -15,9 +15,6 @@ interface CollectionMapPanelProps { /** 'list' = split (map can be expanded); 'map' = full (list collapsed). */ view: 'list' | 'map' onToggleView: () => void - /** Show a "+" to add a place to the current list (real lists only). */ - canAddPlace: boolean - onAddPlace: () => void search: string onSearch: (v: string) => void t: TranslationFn @@ -30,7 +27,7 @@ interface CollectionMapPanelProps { */ export default function CollectionMapPanel({ places, selectedPlaceId, onSelect, onDeselect, dark, overlay, view, onToggleView, - canAddPlace, onAddPlace, search, onSearch, t, + search, onSearch, t, }: CollectionMapPanelProps): React.ReactElement { return (
@@ -55,11 +52,6 @@ export default function CollectionMapPanel({
- {canAddPlace && ( - - )}
onSearch(e.target.value)} placeholder={t('collections.search')} /> diff --git a/client/src/components/Files/FileManagerFilesView.tsx b/client/src/components/Files/FileManagerFilesView.tsx index a64e7ca91..0caf64e90 100644 --- a/client/src/components/Files/FileManagerFilesView.tsx +++ b/client/src/components/Files/FileManagerFilesView.tsx @@ -1,12 +1,15 @@ +import { Fragment } from 'react' import { Upload, FileText, Star } from 'lucide-react' import type { FileManagerState } from './useFileManager' import { FileRow } from './FileManagerRow' +import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions' export function FilesView(S: FileManagerState) { const { can, trip, getRootProps, getInputProps, isDragActive, uploading, t, allowedFileTypes, files, filterType, setFilterType, filteredFiles, } = S + const contribFor = usePluginViewContributions('files', S.tripId) return ( <> {/* Upload zone */} @@ -70,7 +73,15 @@ export function FilesView(S: FileManagerState) {
) : (
- {filteredFiles.map(file => )} + {filteredFiles.map(file => { + const contributions = contribFor(file.id) + return ( + + + {contributions.length > 0 &&
} +
+ ) + })}
)}
diff --git a/client/src/components/Journey/JourneyDetailPageEntryCard.tsx b/client/src/components/Journey/JourneyDetailPageEntryCard.tsx index 3f5d8ee35..179d4a566 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryCard.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryCard.tsx @@ -1,8 +1,10 @@ -import { useState, useRef } from 'react' +import { useState, useRef, useEffect } from 'react' import { createPortal } from 'react-dom' import { MapPin, Clock, MoreHorizontal, Pencil, Trash2 } from 'lucide-react' import { formatLocationName } from '../../utils/formatters' import { useTranslation } from '../../i18n' +import { pluginsApi } from '../../api/client' +import { usePluginStore } from '../../store/pluginStore' import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants' import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers' @@ -21,6 +23,19 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: { const { t } = useTranslation() const [menuOpen, setMenuOpen] = useState(false) const menuBtnRef = useRef(null) + // Extra rows contributed by journalEntryProvider plugins — same pattern as the + // PlaceInspector provider details: fetched only when plugins are active at all, + // fail-safe (the server drops slow/failing providers), only ever additive. + const hasPlugins = usePluginStore((s) => s.plugins.length > 0) + const [providerRows, setProviderRows] = useState }>>([]) + useEffect(() => { + if (!hasPlugins) { setProviderRows([]); return } + let cancelled = false + pluginsApi.journalEntryRows(entry.id) + .then((d) => { if (!cancelled) setProviderRows((d.providers || []).filter((p) => Array.isArray(p.items) && p.items.length > 0)) }) + .catch(() => { if (!cancelled) setProviderRows([]) }) + return () => { cancelled = true } + }, [entry.id, hasPlugins]) const photos = entry.photos || [] const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null @@ -146,6 +161,20 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
)} + + {/* Plugin provider rows — host-vetted label/value/url, plain text only */} + {providerRows.length > 0 && ( +
+ )}
) diff --git a/client/src/components/Layout/BottomNav.tsx b/client/src/components/Layout/BottomNav.tsx index 4e3d787f7..232c09bb8 100644 --- a/client/src/components/Layout/BottomNav.tsx +++ b/client/src/components/Layout/BottomNav.tsx @@ -1,8 +1,9 @@ import { useNavigate, useLocation, useMatch } from 'react-router-dom' import { useAddonStore } from '../../store/addonStore' +import { usePluginStore } from '../../store/pluginStore' import { useSettingsStore } from '../../store/settingsStore' import { useTranslation } from '../../i18n' -import { LayoutGrid, CalendarDays, Globe, Compass, Bookmark, Plus } from 'lucide-react' +import { LayoutGrid, CalendarDays, Globe, Compass, Bookmark, Plus, Blocks } from 'lucide-react' import type { LucideIcon } from 'lucide-react' const ADDON_NAV: Record = { @@ -52,6 +53,9 @@ export default function BottomNav() { const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) const addons = useAddonStore(s => s.addons) const globalAddons = addons.filter(a => a.type === 'global' && a.enabled) + // Page plugins are reachable from the mobile tab bar too, mirroring the desktop + // nav pill (Navbar) — otherwise they were only reachable by typing /plugins/:id. + const pagePlugins = usePluginStore(s => s.plugins).filter(p => p.type === 'page') const location = useLocation() const create = useCreateAction() @@ -61,6 +65,7 @@ export default function BottomNav() { const nav = ADDON_NAV[addon.id] return nav ? [{ to: `/${addon.id}`, label: t(nav.labelKey), icon: nav.icon }] : [] }), + ...pagePlugins.map(p => ({ to: `/plugins/${p.id}`, label: p.name, icon: Blocks })), ] // Split the items so the raised "+" sits dead centre. const splitAt = Math.ceil(items.length / 2) diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx index 2490722ef..d7db52733 100644 --- a/client/src/components/Layout/Navbar.tsx +++ b/client/src/components/Layout/Navbar.tsx @@ -172,6 +172,21 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: )} + {/* Centre slot for page-scoped notices (plugin trip warnings portal into it). + Only mounted on trip pages, where the tab pill above is absent, so the two + never fight over the centre. Zero-size while empty; pointer events stay off + on the wrapper so an empty slot can't swallow clicks. */} + {tripTitle && ( +
+ )} + {/* Spacer */}
diff --git a/client/src/components/Map/MapPluginMarkers.tsx b/client/src/components/Map/MapPluginMarkers.tsx new file mode 100644 index 000000000..4ac642c60 --- /dev/null +++ b/client/src/components/Map/MapPluginMarkers.tsx @@ -0,0 +1,75 @@ +import { useEffect, useMemo, useState } from 'react' +import { Marker, Popup } from 'react-leaflet' +import L from 'leaflet' +import { pluginsApi, type PluginMapMarker } from '../../api/client' + +/** + * Host-rendered overlay for the `mapMarkerProvider` plugin hook (#587). A plugin + * returns bounded marker specs (coordinates + plain text + an allowlisted url); the + * server range-checks + normalizes them, and this layer draws them as plain Leaflet + * markers. Plugin JS NEVER runs on the map canvas — every value here is host-vetted + * data, and the popup renders it as text (the url is already http/https/mailto-only). + * + * Mounted inside the trip map's ; fail-safe — a fetch error just yields + * no extra markers, the core map is untouched. + */ +const TONE_COLORS: Record = { + default: '#4F46E5', + success: '#10b981', + warn: '#f59e0b', + danger: '#ef4444', +} + +function markerIcon(tone: PluginMapMarker['tone']): L.DivIcon { + const color = TONE_COLORS[tone] ?? TONE_COLORS.default + return L.divIcon({ + className: 'plugin-map-marker', + html: ``, + iconSize: [16, 16], + iconAnchor: [8, 8], + popupAnchor: [0, -8], + }) +} + +export function PluginMapMarkers({ tripId }: { tripId?: number | string }) { + const [markers, setMarkers] = useState([]) + + useEffect(() => { + if (tripId == null) { setMarkers([]); return } + let alive = true + pluginsApi.mapMarkers(tripId) + .then(r => { if (alive) setMarkers(r.markers || []) }) + .catch(() => { if (alive) setMarkers([]) }) // fail-safe: no extra markers + return () => { alive = false } + }, [tripId]) + + const icons = useMemo(() => { + const m = new Map() + for (const tone of ['default', 'success', 'warn', 'danger'] as const) m.set(tone, markerIcon(tone)) + return m + }, []) + + if (markers.length === 0) return null + + return ( + <> + {markers.map(mk => ( + + {(mk.label || mk.popupText || mk.url) && ( + +
+ {mk.label &&
{mk.label}
} + {mk.popupText &&
{mk.popupText}
} + {mk.url && ( + + {mk.url} + + )} +
+
+ )} +
+ ))} + + ) +} diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 34918d0e8..bf9e13c37 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -9,6 +9,7 @@ import 'leaflet.markercluster/dist/MarkerCluster.Default.css' import { mapsApi } from '../../api/client' import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons' import ReservationOverlay from './ReservationOverlay' +import { PluginMapMarkers } from './MapPluginMarkers' import { useTransportRoutes } from '../../hooks/useTransportRoutes' import type { Reservation } from '../../types' import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories' @@ -451,6 +452,7 @@ export const MapView = memo(function MapView({ pois = [] as Poi[], onPoiClick, onViewportChange, + tripId, }: any) { const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => ( {poiMarkers} +
{isMobile && { http.get('/api/maps/place-photo/:placeId', () => HttpResponse.json({ photoUrl: null }) ), + http.get('/api/pdf-sections/:tripId', () => + HttpResponse.json({ sections: [] }) + ), ) }) @@ -151,6 +154,22 @@ describe('downloadTripPDF', () => { expect(iframe!.srcdoc).toContain('Day One') }) + it('FE-COMP-TRIPPDF-005b: day is a table with a thead header that repeats on overflow pages (#1471)', async () => { + await downloadTripPDF(richArgs) + const iframe = getIframe() + const srcdoc = iframe!.srcdoc + // The day is a real whose is repeated by the browser's print + // engine on every page an overflowing day spills onto. + expect(srcdoc).toContain('
') + expect(srcdoc).toContain('') + // The dark bar (background/padding/flex) lives in an inner wrapper inside the thead. + expect(srcdoc).toContain('class="day-header-bar"') + // Day content still renders inside the new structure. + expect(srcdoc).toContain('Rome Day') + expect(srcdoc).toContain('Colosseum') + }) + it('FE-COMP-TRIPPDF-006: escHtml prevents XSS in trip title', async () => { const args = { ...minimalArgs, @@ -356,4 +375,35 @@ describe('downloadTripPDF', () => { // The empty-day div should appear (contains the translation key for empty day) expect(iframe!.srcdoc).toContain('dayplan.emptyDay') }) + + it('FE-COMP-TRIPPDF-021: appends plugin pdf sections after the days, escaped', async () => { + server.use( + http.get('/api/pdf-sections/:tripId', () => + HttpResponse.json({ + sections: [{ + pluginId: 'weather', + title: 'Weather Forecast', + paragraphs: ['Sunny all week'], + table: { headers: ['Day', 'Temp'], rows: [['Mon', '24°C']] }, + }], + }) + ), + ) + await downloadTripPDF(richArgs) + const srcdoc = getIframe()!.srcdoc + expect(srcdoc).toContain('class="plugin-section"') + // Plugin text is escHtml'd like the core content — no markup passes through. + expect(srcdoc).not.toContain('Forecast') + expect(srcdoc).toContain('Weather <b>Forecast</b>') + expect(srcdoc).toContain('Sunny all week') + expect(srcdoc).toContain('24°C') + // Sections come after the last day section. + expect(srcdoc.indexOf('class="plugin-sections')).toBeGreaterThan(srcdoc.lastIndexOf('class="day-section')) + }) + + it('FE-COMP-TRIPPDF-022: renders no plugin block when the sections fetch fails (fail-safe)', async () => { + server.use(http.get('/api/pdf-sections/:tripId', () => HttpResponse.error())) + await expect(downloadTripPDF(minimalArgs)).resolves.not.toThrow() + expect(getIframe()!.srcdoc).not.toContain('class="plugin-sections') + }) }) diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index 850158723..40ba37cb7 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -2,7 +2,7 @@ import { createElement } from 'react' import { getCategoryIcon } from '../shared/categoryIcons' import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Sailboat, Bike, CarTaxiFront, Route, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react' -import { accommodationsApi, mapsApi } from '../../api/client' +import { accommodationsApi, mapsApi, pluginsApi } from '../../api/client' import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types' import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder' import { splitReservationDateTime } from '../../utils/formatters' @@ -149,6 +149,11 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor //retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed const accommodations = await accommodationsApi.list(trip.id); + // Sections contributed by pdfSectionProvider plugins — server-normalized plain + // text (counts + lengths capped), appended after the days. Fail-safe: an error + // just means no extra sections, the core export is untouched. + const pluginSections = await pluginsApi.pdfSections(trip.id).then(r => r.sections || []).catch(() => []) + // Pre-fetch place photos (Google, OSM and coords-only places) const photoMap = await fetchPlacePhotos(assignments, places) @@ -375,18 +380,41 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor ` : '' + // A real
so the browser repeats the day header at the top of + // every page an overflowing day spills onto (#1471). CSS `table-header-group` + // on a
is NOT repeated by Chromium's print engine — only real thead is. return ` -
-
- ${escHtml(tr('dayplan.dayN', { n: day.day_number })).toUpperCase()} - ${escHtml(day.title || tr('dayplan.dayN', { n: day.day_number }))} - ${day.date ? `${shortDate(day.date, loc)}` : ''} - ${cost ? `${cost}` : ''} -
-
${accommodationsHtml}${itemsHtml}
-
` +
+ + +
+
+ ${escHtml(tr('dayplan.dayN', { n: day.day_number })).toUpperCase()} + ${escHtml(day.title || tr('dayplan.dayN', { n: day.day_number }))} + ${day.date ? `${shortDate(day.date, loc)}` : ''} + ${cost ? `${cost}` : ''} +
+
+
${accommodationsHtml}${itemsHtml}
+
` }).join('') + // Plugin sections after the days — every value is host-vetted plain text and + // still escHtml'd here (same treatment as the core content above). + const pluginSectionsHtml = pluginSections.length === 0 ? '' : ` +
+ ${pluginSections.map(s => ` +
+
${escHtml(s.title)}
+ ${(s.paragraphs || []).map(p => `

${escHtml(p)}

`).join('')} + ${s.table ? ` + + ${s.table.headers.map(h => ``).join('')} + ${s.table.rows.map(row => `${row.map(cell => ``).join('')}`).join('')} +
${escHtml(h)}
${escHtml(cell)}
` : ''} +
`).join('')} +
` + const html = ` @@ -456,8 +484,10 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor .cover-stat-lbl { font-size: 9px; font-weight: 500; color: rgba(255,255,255,0.4); letter-spacing: 1px; margin-top: 4px; text-transform: uppercase; } /* ── Day ───────────────────────────────────────── */ + /* .day-section is a real ; its day header repeats on overflow pages. */ .page-break { page-break-before: always; } - .day-header { + .day-section { width: 100%; border-collapse: collapse; table-layout: fixed; } + .day-header-bar { background: #0f172a; padding: 11px 28px; display: flex; align-items: center; gap: 8px; } @@ -538,6 +568,15 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor .empty-day { font-size: 9.5px; color: #cbd5e1; font-style: italic; text-align: center; padding: 14px 0; } + /* ── Plugin sections ───────────────────────────── */ + .plugin-sections { padding: 16px 28px 6px; } + .plugin-section { margin-bottom: 16px; page-break-inside: avoid; } + .plugin-section-title { font-size: 12px; font-weight: 600; color: #1e293b; margin-bottom: 6px; padding-bottom: 4px; border-bottom: 1px solid #e2e8f0; } + .plugin-section-text { font-size: 9.5px; color: #334155; line-height: 1.55; margin-bottom: 5px; } + .plugin-section-table { width: 100%; border-collapse: collapse; margin-top: 6px; } + .plugin-section-table th { font-size: 8px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; text-align: left; padding: 4px 8px; border-bottom: 1px solid #e2e8f0; } + .plugin-section-table td { font-size: 9px; color: #334155; padding: 4px 8px; border-bottom: 1px solid #f1f5f9; } + /* ── Print ─────────────────────────────────────── */ @media print { body { margin: 0; } @@ -591,7 +630,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor ${daysHtml} - +${pluginSectionsHtml} ` // Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue) diff --git a/client/src/components/Packing/PackingListPanel.test.tsx b/client/src/components/Packing/PackingListPanel.test.tsx index 05467b623..881a2ca85 100644 --- a/client/src/components/Packing/PackingListPanel.test.tsx +++ b/client/src/components/Packing/PackingListPanel.test.tsx @@ -159,17 +159,17 @@ describe('PackingListPanel', () => { expect(screen.getByText('Documents')).toBeInTheDocument(); }); - it('FE-COMP-PACKING-014: Add category button is shown', () => { + it('FE-COMP-PACKING-014: Add list button is shown', () => { render(); - // The "Add category" button should be present in the toolbar - expect(screen.getByText('Add category')).toBeInTheDocument(); + // The "Add list" button should be present in the toolbar + expect(screen.getByText('Add list')).toBeInTheDocument(); }); it('FE-COMP-PACKING-015: clicking Add Category shows the category name input', async () => { const user = userEvent.setup(); render(); - await user.click(screen.getByText('Add category')); - await screen.findByPlaceholderText('Category name (e.g. Clothing)'); + await user.click(screen.getByText('Add list')); + await screen.findByPlaceholderText('List name (e.g. Clothing)'); }); it('FE-COMP-PACKING-016: delete item button exists and triggers API call', async () => { @@ -340,8 +340,8 @@ describe('PackingListPanel', () => { ); render(); - await user.click(screen.getByText('Add category')); - const input = await screen.findByPlaceholderText('Category name (e.g. Clothing)'); + await user.click(screen.getByText('Add list')); + const input = await screen.findByPlaceholderText('List name (e.g. Clothing)'); await user.type(input, 'Valuables'); await user.keyboard('{Enter}'); @@ -496,7 +496,7 @@ describe('PackingListPanel', () => { // Click "Rename" in the menu await user.click(await screen.findByText('Rename')); - // Category name input appears — type new name and save + // List name input appears — type new name and save const catInput = screen.getByDisplayValue('Clothing'); await user.clear(catInput); await user.type(catInput, 'Apparel'); @@ -858,8 +858,8 @@ describe('PackingListPanel', () => { // It's rendered inside the action buttons group (sm:opacity-0 sm:group-hover:opacity-100) // In jsdom, CSS classes don't apply so the buttons are accessible // The dot button has a circle span inside with category color - // Find all buttons with the 'Change Category' title - const catChangeBtn = screen.getAllByTitle('Change Category'); + // Find all buttons with the 'Move to List' title + const catChangeBtn = screen.getAllByTitle('Move to List'); expect(catChangeBtn.length).toBeGreaterThan(0); await user.click(catChangeBtn[0]); @@ -999,10 +999,10 @@ describe('PackingListPanel', () => { ); const { container } = render(); - // Open context menu and click Delete Category + // Open context menu and click Delete List const moreBtn = container.querySelector('svg.lucide-more-horizontal')?.closest('button'); await user.click(moreBtn!); - await user.click(await screen.findByText('Delete Category')); + await user.click(await screen.findByText('Delete List')); await waitFor(() => { expect(deletedIds).toContain(100); @@ -1059,7 +1059,7 @@ describe('PackingListPanel', () => { render(); // Use fireEvent (no pointer events) to open the category picker — avoids mouseLeave closing picker - const catChangeBtns = screen.getAllByTitle('Change Category'); + const catChangeBtns = screen.getAllByTitle('Move to List'); fireEvent.click(catChangeBtns[0]); // Picker shows available categories — find and click the 'Documents' button (role=button, text=Documents) diff --git a/client/src/components/Packing/PackingListPanelCategoryGroup.tsx b/client/src/components/Packing/PackingListPanelCategoryGroup.tsx index d20a92407..66145b21f 100644 --- a/client/src/components/Packing/PackingListPanelCategoryGroup.tsx +++ b/client/src/components/Packing/PackingListPanelCategoryGroup.tsx @@ -10,6 +10,7 @@ import type { PackingItem, PackingBag } from '../../types' import { katColor } from './packingListPanel.helpers' import type { TripMember, CategoryAssignee } from './usePackingListPanel' import { ArtikelZeile } from './PackingListPanelItemRow' +import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions' import GuestBadge from '../shared/GuestBadge' interface KategorieGruppeProps { @@ -44,6 +45,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen const [offen, setOffen] = useState(true) const [dragId, setDragId] = useState(null) const [overId, setOverId] = useState(null) + const contribFor = usePluginViewContributions('packing', tripId) const handleReorderDrop = (targetId: number) => { const from = dragId @@ -265,18 +267,24 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen {offen && (
- {items.map(item => ( - {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} - tripMembers={tripMembers} currentUserId={currentUserId} onSetSharing={onSetSharing} onClone={onClone} onJoin={onJoin} onLeave={onLeave} - drag={canEdit ? { - isDragging: dragId === item.id, - isOver: overId === item.id && dragId !== null && dragId !== item.id, - onStart: (id) => { setDragId(id); setOverId(null) }, - onOver: (id) => setOverId(id), - onEnd: () => { setDragId(null); setOverId(null) }, - onDrop: handleReorderDrop, - } : undefined} /> - ))} + {items.map(item => { + const contributions = contribFor(item.id) + return ( + + {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} + tripMembers={tripMembers} currentUserId={currentUserId} onSetSharing={onSetSharing} onClone={onClone} onJoin={onJoin} onLeave={onLeave} + drag={canEdit ? { + isDragging: dragId === item.id, + isOver: overId === item.id && dragId !== null && dragId !== item.id, + onStart: (id) => { setDragId(id); setOverId(null) }, + onOver: (id) => setOverId(id), + onEnd: () => { setDragId(null); setOverId(null) }, + onDrop: handleReorderDrop, + } : undefined} /> + {contributions.length > 0 &&
} +
+ ) + })} {/* Inline add item */} {canEdit && (showAddItem ? (
diff --git a/client/src/components/Packing/PackingListPanelItemRow.tsx b/client/src/components/Packing/PackingListPanelItemRow.tsx index f67a92805..0e4e7926f 100644 --- a/client/src/components/Packing/PackingListPanelItemRow.tsx +++ b/client/src/components/Packing/PackingListPanelItemRow.tsx @@ -11,6 +11,7 @@ import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants' import { QuantityInput } from './PackingListPanelQuantityInput' import PackingShareControl from './PackingShareControl' import type { TripMember } from './usePackingListPanel' +import { NumericInput } from '../shared/NumericInput' interface ArtikelZeileProps { item: PackingItem @@ -141,13 +142,14 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel onChange={e => setEditName(e.target.value)} onBlur={handleSaveName} onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }} - style={{ flex: 1, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }} + style={{ flex: 1, minWidth: 0, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }} /> ) : ( canEdit && !item.checked && setEditing(true)} style={{ - flex: 1, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', + flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', + fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', cursor: !canEdit || item.checked ? 'default' : 'text', color: isPlaceholder ? 'var(--text-faint)' : (item.checked ? 'var(--text-faint)' : 'var(--text-primary)'), transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)', @@ -185,13 +187,11 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel {bagTrackingEnabled && (
- { + onValueChange={async raw => { if (!canEdit) return - const raw = e.target.value.replace(/[^0-9]/g, '') const v = raw === '' ? null : parseInt(raw) try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch { toast.error(t('packing.toast.saveError')) } }} diff --git a/client/src/components/Packing/PackingListPanelQuantityInput.test.tsx b/client/src/components/Packing/PackingListPanelQuantityInput.test.tsx new file mode 100644 index 000000000..736a36534 --- /dev/null +++ b/client/src/components/Packing/PackingListPanelQuantityInput.test.tsx @@ -0,0 +1,47 @@ +// FE-COMP-PACKING-082 to FE-COMP-PACKING-083 +import { vi } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'; +import { QuantityInput } from './PackingListPanelQuantityInput'; + +describe('QuantityInput', () => { + it('FE-COMP-PACKING-082: selects the current value on focus so typing replaces it', async () => { + render( {}} />); + const input = screen.getByRole('textbox') as HTMLInputElement; + + fireEvent.focus(input); + + await waitFor(() => { + expect(input.selectionStart).toBe(0); + expect(input.selectionEnd).toBe(1); + }); + }); + + it('FE-COMP-PACKING-083: commits the typed quantity on blur', async () => { + const onSave = vi.fn(); + render(); + const input = screen.getByRole('textbox') as HTMLInputElement; + + fireEvent.change(input, { target: { value: '6' } }); + fireEvent.blur(input); + + await waitFor(() => expect(onSave).toHaveBeenCalledWith(6)); + }); + + it('FE-COMP-PACKING-084: #1513 focus-then-type replaces the value instead of appending to it', async () => { + // The end-to-end shape of the bug: with the caret parked at the end, typing 6 over a + // quantity of 1 committed 16. Selecting on focus makes the keystroke overwrite. + const onSave = vi.fn(); + render(); + const input = screen.getByRole('textbox') as HTMLInputElement; + + fireEvent.focus(input); + await waitFor(() => expect(input.selectionEnd).toBe(1)); + + // A selected value means the browser replaces it — simulate the resulting input event. + fireEvent.change(input, { target: { value: '6' } }); + fireEvent.blur(input); + + await waitFor(() => expect(onSave).toHaveBeenCalledWith(6)); + expect(onSave).not.toHaveBeenCalledWith(16); + }); +}); diff --git a/client/src/components/Packing/PackingListPanelQuantityInput.tsx b/client/src/components/Packing/PackingListPanelQuantityInput.tsx index 16bd04e6c..0b7ea26ab 100644 --- a/client/src/components/Packing/PackingListPanelQuantityInput.tsx +++ b/client/src/components/Packing/PackingListPanelQuantityInput.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react' +import { NumericInput } from '../shared/NumericInput' export function QuantityInput({ value, onSave }: { value: number; onSave: (qty: number) => void }) { const [local, setLocal] = useState(String(value)) @@ -12,10 +13,9 @@ export function QuantityInput({ value, onSave }: { value: number; onSave: (qty: return (
- setLocal(e.target.value.replace(/\D/g, ''))} + onValueChange={setLocal} onBlur={commit} onKeyDown={e => { if (e.key === 'Enter') { commit(); (e.target as HTMLInputElement).blur() } }} style={{ width: 24, border: 'none', outline: 'none', background: 'transparent', fontSize: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'right', fontFamily: 'inherit', color: 'var(--text-secondary)', padding: 0 }} diff --git a/client/src/components/Planner/AddressInput.tsx b/client/src/components/Planner/AddressInput.tsx new file mode 100644 index 000000000..dc7230f02 --- /dev/null +++ b/client/src/components/Planner/AddressInput.tsx @@ -0,0 +1,114 @@ +import { useEffect, useRef, useState } from 'react' +import { MapPin } from 'lucide-react' +import { mapsApi } from '../../api/client' +import { useTranslation } from '../../i18n' + +interface Props { + value: string + onChange: (value: string) => void + placeholder?: string + className?: string +} + +// Free-text address input with location autocomplete, backed by the same maps +// search as LocationSelect. Unlike LocationSelect the typed text is +// authoritative: every keystroke reaches the parent so a hand-written address +// is never lost, and picking a suggestion just replaces the text (#1496). +export default function AddressInput({ value, onChange, placeholder, className }: Props) { + const { t, locale } = useTranslation() + const [open, setOpen] = useState(false) + const [results, setResults] = useState([]) + const [highlight, setHighlight] = useState(-1) + const [loading, setLoading] = useState(false) + const wrapRef = useRef(null) + const debounceRef = useRef | null>(null) + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (!wrapRef.current?.contains(e.target as Node)) setOpen(false) + } + if (open) document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [open]) + + useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current) }, []) + + // Search on typing only (not on focus or external value changes), so opening + // a modal with a saved address doesn't fire a request. + const search = (text: string) => { + if (debounceRef.current) clearTimeout(debounceRef.current) + const trimmed = text.trim() + if (trimmed.length < 3) { setResults([]); setLoading(false); return } + debounceRef.current = setTimeout(async () => { + setLoading(true) + try { + const data = await mapsApi.search(trimmed, locale) + setResults(data.places || []) + setHighlight(-1) + } catch { + setResults([]) + } finally { + setLoading(false) + } + }, 320) + } + + const pick = (r: any) => { + if (debounceRef.current) clearTimeout(debounceRef.current) + onChange(r.address || r.name || '') + setOpen(false) + setResults([]) + setLoading(false) + } + + const onKey = (e: React.KeyboardEvent) => { + if (!open || results.length === 0) return + if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(h => Math.min(h + 1, results.length - 1)) } + else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight(h => Math.max(h - 1, 0)) } + else if (e.key === 'Enter' && highlight >= 0) { e.preventDefault(); pick(results[highlight]) } + else if (e.key === 'Escape') setOpen(false) + } + + return ( +
+ { onChange(e.target.value); setOpen(true); search(e.target.value) }} + onFocus={() => setOpen(true)} + onKeyDown={onKey} + className={className} + /> + {open && (loading || results.length > 0) && ( +
+ {loading && results.length === 0 && ( +
{t('common.loading')}
+ )} + {results.map((r, i) => ( + + ))} +
+ )} +
+ ) +} diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index 37b6ed4d8..d1327787c 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -5,6 +5,9 @@ import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind const RES_TYPE_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, transit: TramFront, event: Ticket, tour: Users, other: FileText } const RES_TYPE_COLORS = { flight: '#3b82f6', hotel: '#8b5cf6', restaurant: '#ef4444', train: '#06b6d4', car: '#6b7280', cruise: '#0ea5e9', transit: '#7c3aed', event: '#f59e0b', tour: '#10b981', other: '#6b7280' } import { weatherApi, accommodationsApi } from '../../api/client' +import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions' +import { usePluginStore } from '../../store/pluginStore' +import PluginFrame from '../Plugins/PluginFrame' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' import CustomSelect from '../shared/CustomSelect' @@ -103,6 +106,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri hotelForm, setHotelForm, handleSelectPlace, handleSaveAccommodation, updateAccommodationField, handleRemoveAccommodation, } = useDayDetail(day, days, tripId, lat, lng, language, onAccommodationChange) + // Plugin-contributed columns/actions for the day view, keyed by day id (#plugins). + // day can be null (panel closed) and hooks must run before the early return, so guard it. + const dayContributions = usePluginViewContributions('day', tripId)(day?.id ?? -1) + // Plugins that declared a day-detail slot mount at the bottom of this panel, + // scoped to the open day. Inline-filter like the place-detail site. + const dayDetailPlugins = usePluginStore((s) => s.plugins).filter((p) => p.type === 'widget' && p.slot === 'day-detail') // Publish the panel's live height as a root CSS var so the map's mobile GPS // button can sit just above the panel instead of being hidden behind it (#1348). @@ -274,6 +283,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri )} {/* ── Reservations for this day's assignments ── */} + {dayContributions.length > 0 && } {(() => { const dayAssignments = assignments[String(day.id)] || [] const dayReservations = reservations.filter(r => { @@ -336,6 +346,17 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri setDayAccommodations={setDayAccommodations} setAccommodation={setAccommodation} handleSaveAccommodation={handleSaveAccommodation} onAccommodationChange={onAccommodationChange} />
+ + {/* Day-detail plugin slots: sandboxed, scoped to this day. */} + {dayDetailPlugins.length > 0 && ( +
+ {dayDetailPlugins.map((p) => ( +
+ +
+ ))} +
+ )}
diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index a4763248d..42c15af61 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -1924,4 +1924,41 @@ describe('DayPlanSidebar', () => { expect(await screen.findByText('1000 m')).toBeInTheDocument() expect(await screen.findByText('2000 m')).toBeInTheDocument() }) + + it('FE-PLANNER-DAYPLAN-106: leg distance survives a car rental on its middle days (#1504)', async () => { + const user = userEvent.setup() + const { calculateRouteWithLegs } = await import('../Map/RouteCalculator') + vi.mocked(calculateRouteWithLegs as any).mockImplementation((wp: any) => Promise.resolve({ + distanceText: '2 km', durationText: '10 min', + legs: Array.from({ length: Math.max(0, (wp?.length ?? 0) - 1) }, () => ({ + distanceText: '2 km', durationText: '10 min', drivingText: '10 min', walkingText: '25 min', + })), + })) + // A rental spanning days 10–12: on day 11 (middle) its row is not rendered in + // the timeline, so the through-leg between the places around it must stay keyed + // to the place — re-keying it to the hidden car row would drop the distance. + const car = { + ...buildReservation({ id: 400, type: 'car', title: 'Rental car', day_id: 10 }), + end_day_id: 12, + day_positions: { 11: 0.5 }, + } + const days = [ + buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }), + buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }), + buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }), + ] + const assigns = { + '11': [ + buildAssignment({ id: 1, day_id: 11, order_index: 0, place: buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }) }), + buildAssignment({ id: 2, day_id: 11, order_index: 1, place: buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }) }), + ], + } + render() + // Only day 2 has places, so it renders the sole Route toggle. + await user.click(screen.getByRole('button', { name: 'Route' })) + expect(await screen.findByText('2 km')).toBeInTheDocument() + }) }) diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index d37dfdfa2..4bd3dbaeb 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -22,7 +22,7 @@ import { useAddonStore } from '../../store/addonStore' import { useSaveToCollectionStore } from '../../store/saveToCollectionStore' import { placeToSaveTarget } from '../Collections/saveTarget' import { useTranslation } from '../../i18n' -import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels } from '../../utils/dayOrder' +import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg } from '../../utils/dayOrder' import { TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportRouteEndpoints, getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, @@ -455,9 +455,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { cur = [] curHasPlace = false if (to) cur.push({ id: r.id, lat: to.lat, lng: to.lng }) - } else if (cur.length > 0) { + } else if (cur.length > 0 && !(r.type === 'car' && getSpanPhase(r, dayId) === 'middle')) { // No location: ignore for routing, but attribute the through-leg to the // booking so its distance/duration shows under it (purely cosmetic). + // Not for a car rental's middle days though — that row isn't rendered + // in the timeline, so re-keying would drop the leg entirely (#1504). cur[cur.length - 1] = { ...cur[cur.length - 1], id: r.id } } } @@ -475,22 +477,23 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { const endHotel = bookends?.evening // Waypoints include transport endpoints (a car return, a taxi/train arrival), so the hotel // legs connect even when the day starts or ends with a booking rather than a place. Track - // whether each is a place so we can skip a hotel↔transport leg that isn't real: on a day-1 - // arrival the check-in hotel never drove to the departure airport (#1321). - const wayPts: { lat: number; lng: number; isPlace: boolean }[] = [] + // whether each is a place and its time so the bookend decision can drop a leg that isn't + // real: a check-in hotel never drove to a departure airport (#1321), and a place timed before + // check-in / after check-out means you weren't at the hotel then (#1465). + const wayPts: { lat: number; lng: number; isPlace: boolean; time: string | null }[] = [] for (const it of merged) { if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) { - wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng, isPlace: true }) + wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng, isPlace: true, time: it.data.place?.place_time ?? null }) } else if (it.type === 'transport') { const { from, to } = getTransportRouteEndpoints(it.data, dayId) - if (from) wayPts.push({ lat: from.lat, lng: from.lng, isPlace: false }) - if (to) wayPts.push({ lat: to.lat, lng: to.lng, isPlace: false }) + if (from) wayPts.push({ lat: from.lat, lng: from.lng, isPlace: false, time: null }) + if (to) wayPts.push({ lat: to.lat, lng: to.lng, isPlace: false, time: null }) } } const firstWay = wayPts[0] const lastWay = wayPts[wayPts.length - 1] - const wantTop = !!(startHotel && firstWay && (firstWay.isPlace || bookends?.morningIsSleptHere)) - const wantBottom = !!(endHotel && lastWay && (lastWay.isPlace || bookends?.eveningIsOvernight)) + const wantTop = !!(startHotel && firstWay && bookends && day && shouldDrawMorningLeg(bookends, day, firstWay)) + const wantBottom = !!(endHotel && lastWay && bookends && day && shouldDrawEveningLeg(bookends, day, lastWay)) return { runs, startHotel, endHotel, firstWay, lastWay, wantTop, wantBottom } } @@ -2358,11 +2361,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP // Bookend the Google Maps route with the day's accommodation the // same way the drawn map route does (routeBookends is null when // "optimize from accommodation" is off), so hotels aren't dropped - // from the exported route (#1372). - const stops = getDayAssignments(day.id).map(a => a.place).filter(p => p?.lat != null && p?.lng != null) as { lat: number; lng: number }[] - const morning = routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null + // from the exported route (#1372) — but only when the leg is real: + // no hotel prepended before an early check-in-day stop, none appended + // after a post-check-out stop (#1465). + const dayStops = getDayAssignments(day.id).filter(a => a.place?.lat != null && a.place?.lng != null) + const stops = dayStops.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng! })) + const firstStop = dayStops[0] ? { isPlace: true, time: dayStops[0].place?.place_time ?? null } : undefined + const lastAssignment = dayStops[dayStops.length - 1] + const lastStop = lastAssignment ? { isPlace: true, time: lastAssignment.place?.place_time ?? null } : undefined + const drawMorning = !!routeBookends && shouldDrawMorningLeg(routeBookends, day, firstStop) + const drawEvening = !!routeBookends && shouldDrawEveningLeg(routeBookends, day, lastStop) + const morning = drawMorning && routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null ? { lat: routeBookends.morning.place_lat, lng: routeBookends.morning.place_lng } : null - const evening = routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null + const evening = drawEvening && routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null ? { lat: routeBookends.evening.place_lat, lng: routeBookends.evening.place_lng } : null const url = generateGoogleMapsUrl([...(morning ? [morning] : []), ...stops, ...(evening ? [evening] : [])]) if (url) window.open(url, '_blank', 'noopener,noreferrer') diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 4464718a9..b50e7c67c 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -14,6 +14,7 @@ import CustomTimePicker from '../shared/CustomTimePicker' import { DEFAULT_FORM, isGoogleMapsUrl, type PlaceFormData } from './PlaceFormModal.helpers' import { getApiErrorMessage } from '../../utils/apiError' import type { Place, Category, Assignment } from '../../types' +import { NumericInput } from '../shared/NumericInput' // The submit payload mirrors the form, but lat/lng are parsed to numbers and // category_id is normalised, plus any files chosen before the place existed. @@ -698,11 +699,10 @@ export default function PlaceFormModal(props: PlaceFormModalProps) { className="form-input" />
- handleChange('lat', e.target.value)} + onValueChange={v => handleChange('lat', v)} onPaste={e => { const text = e.clipboardData.getData('text').trim() const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/) @@ -715,11 +715,10 @@ export default function PlaceFormModal(props: PlaceFormModalProps) { placeholder={t('places.formLat')} className="form-input" /> - handleChange('lng', e.target.value)} + onValueChange={v => handleChange('lng', v)} placeholder={t('places.formLng')} className="form-input" /> diff --git a/client/src/components/Planner/PlacesSidebarList.tsx b/client/src/components/Planner/PlacesSidebarList.tsx index 7492cc9df..9b2d75546 100644 --- a/client/src/components/Planner/PlacesSidebarList.tsx +++ b/client/src/components/Planner/PlacesSidebarList.tsx @@ -1,12 +1,16 @@ +import { Fragment } from 'react' import { MemoPlaceRow } from './PlacesSidebarRow' import type { SidebarState } from './usePlacesSidebar' +import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions' export function PlacesList(S: SidebarState) { const { filtered, scrollContainerRef, onScrollTopChange, filter, t, canEditPlaces, onAddPlace, categories, selectedPlaceId, plannedIds, inDaySet, selectedIds, selectMode, selectedDayId, - isMobile, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow, + isMobile, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow, tripId, } = S + // Plugin-contributed columns/actions for the places view, keyed by place id (#plugins). + const contribFor = usePluginViewContributions('places', tripId) return (
onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}> {filtered.length === 0 ? ( @@ -25,27 +29,32 @@ export function PlacesList(S: SidebarState) { const isPlanned = plannedIds.has(place.id) const inDay = inDaySet.has(place.id) const isChecked = selectedIds.has(place.id) + const contributions = contribFor(place.id) return ( - + + + {contributions.length > 0 && ( +
+ )} +
) }) )} diff --git a/client/src/components/Planner/ReservationModal.test.tsx b/client/src/components/Planner/ReservationModal.test.tsx index f1f2a7bfc..69e8994bf 100644 --- a/client/src/components/Planner/ReservationModal.test.tsx +++ b/client/src/components/Planner/ReservationModal.test.tsx @@ -782,4 +782,62 @@ describe('ReservationModal', () => { await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave.mock.calls[0][0].assignment_id).toBeNull(); }); + + // ── Hotel address persistence (issue #1496) ───────────────────────────────── + + it('FE-PLANNER-RESMODAL-053: editing a hotel address sends the typed value even with a place linked', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const place = buildPlace({ id: 5, name: 'Grand Hotel', address: 'Old Street 1' }); + const days = [ + buildDay({ id: 1, trip_id: 1, date: '2026-05-01', day_number: 1 }), + buildDay({ id: 2, trip_id: 1, date: '2026-05-02', day_number: 2 }), + ]; + const res = buildReservation({ + id: 3, title: 'Grand Hotel', type: 'hotel', accommodation_id: 8, + } as any); + const acc = { id: 8, trip_id: 1, place_id: 5, start_day_id: 1, end_day_id: 2 } as any; + + render( + + ); + + // Address field is pre-filled from the linked place + const addressInput = screen.getByPlaceholderText(/Address, Airport/i); + expect(addressInput).toHaveValue('Old Street 1'); + + await userEvent.clear(addressInput); + await userEvent.type(addressInput, 'New Street 2'); + await userEvent.click(screen.getByRole('button', { name: /^Update$/i })); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + const saved = onSave.mock.calls[0][0]; + // The typed address must reach the save handler — before #1496 it was + // dropped whenever a place was linked and the old address reappeared. + expect(saved.location).toBe('New Street 2'); + expect(saved.create_accommodation?.address).toBe('New Street 2'); + expect(saved.create_accommodation?.place_id).toBe(5); + }); + + it('FE-PLANNER-RESMODAL-054: hotel address is kept in location when no days or place are set', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i })); + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Hotel Test'); + await userEvent.type(screen.getByPlaceholderText(/Address, Airport/i), 'Main Road 3'); + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + const saved = onSave.mock.calls[0][0]; + // No day range → no accommodation, but the address must not be lost + expect(saved.create_accommodation).toBeUndefined(); + expect(saved.location).toBe('Main Road 3'); + }); }); diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index f8a775f1f..369cfb282 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -5,6 +5,7 @@ import { useTripStore } from '../../store/tripStore' import { useAddonStore } from '../../store/addonStore' import Modal from '../shared/Modal' import CustomSelect from '../shared/CustomSelect' +import AddressInput from './AddressInput' import { Hotel, Utensils, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' @@ -147,7 +148,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p hotel_place_id: editAcc?.place_id || '', hotel_start_day: editAcc?.start_day_id || '', hotel_end_day: editAcc?.end_day_id || '', - hotel_address: places.find(p => p.id == editAcc?.place_id)?.address || '', + // The linked place carries the address; reservations saved without a + // place (or before the accommodation existed) keep it in location. + hotel_address: places.find(p => p.id == editAcc?.place_id)?.address || reservation.location || '', }) } else if (prefill) { // Review-before-save: populate from a parsed import item, stay in create mode. @@ -202,9 +205,10 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p if (!acc) return setForm(prev => { if (prev.hotel_place_id !== '' || prev.hotel_start_day !== '' || prev.hotel_end_day !== '') return prev - return { ...prev, hotel_place_id: acc.place_id, hotel_start_day: acc.start_day_id, hotel_end_day: acc.end_day_id } + const accPlace = places.find(p => p.id == acc.place_id) + return { ...prev, hotel_place_id: acc.place_id, hotel_start_day: acc.start_day_id, hotel_end_day: acc.end_day_id, hotel_address: accPlace?.address || prev.hotel_address } }) - }, [accommodations, isOpen, reservation]) + }, [accommodations, isOpen, reservation, places]) const set = (field, value) => setForm(prev => ({ ...prev, [field]: value })) @@ -240,7 +244,10 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p title: form.title, type: form.type, status: form.status, reservation_time: form.type === 'hotel' ? null : (form.reservation_time || null), reservation_end_time: form.type === 'hotel' ? null : (combinedEndTime || null), - location: form.location, confirmation_number: form.confirmation_number, + // Hotels show the address field instead of location — persist it on the + // reservation itself so it survives even without days/place (#1496). + location: form.type === 'hotel' ? form.hotel_address : form.location, + confirmation_number: form.confirmation_number, notes: form.notes, url: form.url, assignment_id: (form.type === 'hotel' && !form.accommodation_id) ? null : (form.assignment_id || null), @@ -260,6 +267,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p venue: (!form.hotel_place_id && (form.hotel_address || form.title)) ? { name: form.title, address: form.hotel_address || null } : null, + // The typed address, so the save handler can write it through to a + // linked place — an edited address used to be silently dropped (#1496). + address: form.hotel_address || null, // Tolerate a single resolved end of the range (a one-night stay or a date // that only matched one trip day) so the accommodation is still created. start_day_id: form.hotel_start_day || form.hotel_end_day, @@ -504,7 +514,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p {form.type !== 'hotel' && (
- set('location', e.target.value)} + set('location', v)} placeholder={t('reservations.locationPlaceholder')} className={inputClass} />
)} @@ -542,11 +552,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p const p = places.find(pl => pl.id === value) setForm(prev => { const next = { ...prev, hotel_place_id: value } - if (!value) { - next.location = '' - } else if (p) { + if (value && p) { if (!prev.title) next.title = p.name - if (!prev.location && p.address) next.location = p.address + // Show the picked hotel's address; keep a hand-typed one + // if the place has none. + next.hotel_address = p.address || prev.hotel_address } return next }) @@ -609,7 +619,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
- set('hotel_address', e.target.value)} + set('hotel_address', v)} placeholder={t('reservations.locationPlaceholder')} className={inputClass} />
diff --git a/client/src/components/Planner/ReservationsPanel.test.tsx b/client/src/components/Planner/ReservationsPanel.test.tsx index 9666b2bf2..a9b931bc2 100644 --- a/client/src/components/Planner/ReservationsPanel.test.tsx +++ b/client/src/components/Planner/ReservationsPanel.test.tsx @@ -436,4 +436,30 @@ describe('ReservationsPanel', () => { expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument(); expect(screen.getByText(/08:30/)).toBeInTheDocument(); }); + + // ── Chronological sorting (#1507) ─────────────────────────────────────────── + + it('FE-PLANNER-RESP-044: cards are ordered chronologically, day-linked entries by their day date', () => { + const day1 = buildDay({ id: 201, date: '2025-06-02', day_number: 2 } as any); + const day2 = buildDay({ id: 202, date: '2025-06-04', day_number: 4 } as any); + const dated = buildReservation({ title: 'Dated flight', type: 'flight', status: 'pending', reservation_time: '2025-06-03T09:00', created_at: '2025-05-01T00:00:00.000Z' }); + const dayOnly = buildReservation({ title: 'Day-only train', type: 'train', status: 'pending', reservation_time: 'T10:00', day_id: 201, created_at: '2025-05-02T00:00:00.000Z' } as any); + const late = buildReservation({ title: 'Late bus', type: 'bus', status: 'pending', reservation_time: null, day_id: 202, created_at: '2025-05-03T00:00:00.000Z' } as any); + const undated = buildReservation({ title: 'Undated taxi', type: 'taxi', status: 'pending', created_at: '2025-04-01T00:00:00.000Z' }); + render(); + const text = document.body.textContent || ''; + const order = ['Day-only train', 'Dated flight', 'Late bus', 'Undated taxi'].map(t => text.indexOf(t)); + expect(order.every(i => i >= 0)).toBe(true); + expect([...order].sort((a, b) => a - b)).toEqual(order); + }); + + it('FE-PLANNER-RESP-045: hotel sorts by its accommodation start day, not a stale day_id', () => { + const day1 = buildDay({ id: 301, date: '2025-06-01', day_number: 1 } as any); + const day2 = buildDay({ id: 302, date: '2025-06-05', day_number: 5 } as any); + const hotel = buildReservation({ title: 'Hotel stay', type: 'hotel', status: 'pending', day_id: 301, accommodation_start_day_id: 302 } as any); + const flight = buildReservation({ title: 'Mid flight', type: 'flight', status: 'pending', reservation_time: '2025-06-03T12:00' }); + render(); + const text = document.body.textContent || ''; + expect(text.indexOf('Mid flight')).toBeLessThan(text.indexOf('Hotel stay')); + }); }); diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index a083b1eaf..39a7ed6b3 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -17,6 +17,10 @@ import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types' +import type { ViewContribution } from '../../api/client' +import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions' +import { usePluginStore, type ActivePlugin } from '../../store/pluginStore' +import PluginFrame from '../Plugins/PluginFrame' import { splitReservationDateTime, formatTime } from '../../utils/formatters' interface AssignmentLookupEntry { @@ -76,9 +80,12 @@ interface ReservationCardProps { assignmentLookup: Record canEdit: boolean days?: Day[] + contributions?: ViewContribution[] + /** Plugins that declared a reservation-detail slot — mounted at the card's foot, scoped to this reservation. */ + detailPlugins?: ActivePlugin[] } -function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit, days = [] }: ReservationCardProps) { +function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit, days = [], contributions = [], detailPlugins = [] }: ReservationCardProps) { const { toggleReservationStatus } = useTripStore() const toast = useToast() const { t, locale } = useTranslation() @@ -407,6 +414,19 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo )}
+ + + {/* Reservation-detail plugin slots: sandboxed, scoped to this reservation. */} + {detailPlugins.length > 0 && ( +
+ {detailPlugins.map(p => ( +
+ +
+ ))} +
+ )} + {/* Delete confirmation */} {showDeleteConfirm && ReactDOM.createPortal(
void onDelete: (id: number) => void canEdit: boolean + tripId: number + contributions?: ViewContribution[] + detailPlugins?: ActivePlugin[] }) { const { t, locale } = useTranslation() const timeFormat = useSettingsStore(st => st.settings.time_format) || '24h' @@ -566,6 +589,18 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit }: {
)} + + {/* Reservation-detail plugin slots: sandboxed, scoped to this journey. The + card itself is clickable, so keep frame interactions from opening it. */} + {detailPlugins.length > 0 && ( +
e.stopPropagation()} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> + {detailPlugins.map(p => ( +
+ +
+ ))} +
+ )} {confirmOpen && ReactDOM.createPortal(
{ e.stopPropagation(); setConfirmOpen(false) }}>
e.stopPropagation()}> @@ -599,9 +634,12 @@ interface ReservationsPanelProps { onNavigateToFiles: () => void titleKey?: string addManualKey?: string + /** Which plugin view this panel represents — the transports tab is its own + * contribution view, the bookings tab stays 'reservations'. */ + contributionView?: 'reservations' | 'transports' } -export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onAirTrailImport, airTrailAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) { +export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onAirTrailImport, airTrailAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual', contributionView = 'reservations' }: ReservationsPanelProps) { const { t, locale } = useTranslation() const can = useCanDo() const trip = useTripStore((s) => s.trip) @@ -609,6 +647,12 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint')) const storageKey = `trek-reservation-filters-${tripId}` + // Plugin-contributed columns/actions for this view, keyed by reservation id (#plugins). + // The bookings and transports tabs share this panel but are distinct plugin views. + const contribFor = usePluginViewContributions(contributionView, tripId) + // Plugins that declared a reservation-detail slot mount at the foot of each card, + // scoped to that reservation. Filtered inline like the place-/day-detail sites. + const reservationDetailPlugins = usePluginStore((s) => s.plugins).filter((p) => p.type === 'widget' && p.slot === 'reservation-detail') const [typeFilters, setTypeFilters] = useState>(() => { try { const saved = sessionStorage.getItem(storageKey) @@ -631,10 +675,37 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme typeFilters.size === 0 ? reservations : reservations.filter(r => typeFilters.has(r.type)), [reservations, typeFilters]) + // Chronological order (#1507): day-linked transports often carry no date in + // reservation_time, so resolve each entry to an effective departure datetime — + // the stamped date when there is one, else the linked day's date (the + // accommodation start day for hotels, matching the card label). Entries + // without any resolvable date sink to the bottom; creation order breaks ties. + const sorted = useMemo(() => { + const dayDates = new Map(days.map(d => [d.id, d.date])) + const sortKey = (r: Reservation): string | null => { + const { date, time } = splitReservationDateTime(r.reservation_time) + const dayId = r.type === 'hotel' ? (r.accommodation_start_day_id ?? r.day_id) : r.day_id + const effectiveDate = date ?? (dayId != null ? dayDates.get(dayId) : null) + if (!effectiveDate) return null + return `${effectiveDate}T${time ?? '00:00'}` + } + return filtered + .map(r => ({ r, key: sortKey(r) })) + .sort((a, b) => { + if (a.key !== b.key) { + if (a.key === null) return 1 + if (b.key === null) return -1 + return a.key < b.key ? -1 : 1 + } + return (a.r.created_at ?? '').localeCompare(b.r.created_at ?? '') + }) + .map(({ r }) => r) + }, [filtered, days]) + // Automated public transit (#1065) gets its own section — journeys planned via // the transit search live alongside manual transports without mixing in. - const transitEntries = filtered.filter(r => r.type === 'transit') - const nonTransit = filtered.filter(r => r.type !== 'transit') + const transitEntries = sorted.filter(r => r.type === 'transit') + const nonTransit = sorted.filter(r => r.type !== 'transit') const allPending = nonTransit.filter(r => r.status !== 'confirmed') const allConfirmed = nonTransit.filter(r => r.status === 'confirmed') const total = filtered.length @@ -776,17 +847,17 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme <> {transitEntries.length > 0 && (
- {transitEntries.map(r => )} + {transitEntries.map(r => )}
)} {allPending.length > 0 && (
- {allPending.map(r => )} + {allPending.map(r => )}
)} {allConfirmed.length > 0 && (
- {allConfirmed.map(r => )} + {allConfirmed.map(r => )}
)} diff --git a/client/src/components/Planner/TransitSearchPanel.test.tsx b/client/src/components/Planner/TransitSearchPanel.test.tsx index fc3213f4b..e5b079407 100644 --- a/client/src/components/Planner/TransitSearchPanel.test.tsx +++ b/client/src/components/Planner/TransitSearchPanel.test.tsx @@ -135,6 +135,43 @@ describe('TransitSearchPanel', () => { expect(cards[0].textContent).toContain('08:40') }) + // Cross-timezone route (#1479): origin Europe/Berlin (UTC+2 in June), + // destination Europe/London (UTC+1). Same wall-clock 09:00 resolves to a + // different UTC instant depending on which end the user anchors to. + async function pickFromAndLondon(user: ReturnType) { + const [fromInput, toInput] = screen.getAllByPlaceholderText('Search stop or station…') + await user.click(fromInput) + await user.click(await screen.findByText('Fernsehturm')) + + transitApiMock.geocode.mockResolvedValueOnce({ results: [{ name: 'London Victoria', lat: 51.5074, lng: -0.1278, type: 'STOP', area: 'England' }] }) + await user.click(toInput) + await user.type(toInput, 'Lon') + await user.click(await screen.findByText(/London Victoria/)) + } + + it('FE-PLANNER-TRANSIT-007: depart-by anchors the entered time to the origin timezone', async () => { + const user = userEvent.setup() + transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] }) + render() + await pickFromAndLondon(user) + await user.click(screen.getByRole('button', { name: /^Search$/ })) + await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled()) + // Default 09:00 in Berlin (UTC+2) → 07:00Z. + expect(transitApiMock.plan.mock.calls[0][0]).toMatchObject({ arriveBy: false, time: '2025-06-01T07:00:00.000Z' }) + }) + + it('FE-PLANNER-TRANSIT-008: arrive-by anchors the entered time to the destination timezone', async () => { + const user = userEvent.setup() + transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] }) + render() + await pickFromAndLondon(user) + await user.click(screen.getByRole('button', { name: 'Arrive' })) + await user.click(screen.getByRole('button', { name: /^Search$/ })) + await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled()) + // 09:00 arrival in London (UTC+1) → 08:00Z, not 07:00Z. + expect(transitApiMock.plan.mock.calls[0][0]).toMatchObject({ arriveBy: true, time: '2025-06-01T08:00:00.000Z' }) + }) + it('FE-PLANNER-TRANSIT-006: swap exchanges from and to', async () => { const user = userEvent.setup() render() diff --git a/client/src/components/Planner/TransitSearchPanel.tsx b/client/src/components/Planner/TransitSearchPanel.tsx index 3a7a315d3..0ea432546 100644 --- a/client/src/components/Planner/TransitSearchPanel.tsx +++ b/client/src/components/Planner/TransitSearchPanel.tsx @@ -368,7 +368,10 @@ export default function TransitSearchPanel({ day, days, places, accommodations = setExpandedIdx(null) try { const tzFrom = tzAt(from.lat, from.lng) - const timeIso = localToUtcIso(day.date, time, tzFrom) + const tzTo = tzAt(to.lat, to.lng) + // Depart-by anchors the entered time at the origin; arrive-by anchors it at + // the destination (#1479), so each mode must convert with the matching zone. + const timeIso = localToUtcIso(day.date, time, arriveBy ? tzTo : tzFrom) const allModes = activeModes.size === MODE_GROUPS.length const modes = allModes ? undefined : MODE_GROUPS.filter(m => activeModes.has(m.key)).map(m => m.modes).join(',') const d = await transitApi.plan({ from: `${from.lat},${from.lng}`, to: `${to.lat},${to.lng}`, time: timeIso, arriveBy, modes }) diff --git a/client/src/components/Planner/TransportModal.test.tsx b/client/src/components/Planner/TransportModal.test.tsx index 9cd153469..69c6c2ffd 100644 --- a/client/src/components/Planner/TransportModal.test.tsx +++ b/client/src/components/Planner/TransportModal.test.tsx @@ -11,6 +11,8 @@ import { buildUser, buildTrip, buildDay, + buildPlace, + buildAssignment, buildReservation, buildTripFile, } from '../../../tests/helpers/factories'; @@ -385,12 +387,38 @@ describe('TransportModal', () => { expect(screen.queryByPlaceholderText(/e\.g\. Lufthansa/i)).not.toBeInTheDocument(); }); + it('FE-PLANNER-TRANSMODAL-022b: a trip without start/end dates only offers the manual form', () => { + render(); + expect(screen.queryByRole('button', { name: 'Automated transport' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Manual transport' })).not.toBeInTheDocument(); + expect(screen.getByPlaceholderText(/e\.g\. Lufthansa/i)).toBeInTheDocument(); + }); + it('FE-PLANNER-TRANSMODAL-023: initialAutomated opens straight in the transit search with the day preset', () => { const days = [{ id: 10, trip_id: 1, day_number: 1, date: '2025-06-01', title: 'Day 1' }] as any; render(); expect(screen.getAllByPlaceholderText('Search stop or station…')).toHaveLength(2); }); + it('FE-PLANNER-TRANSMODAL-028: automated quick picks only offer the chosen day\'s places (#1460)', async () => { + const days = [ + buildDay({ id: 10, date: '2025-06-01' }), + buildDay({ id: 11, date: '2025-06-02' }), + ]; + const louvre = buildPlace({ id: 1, name: 'Louvre' }); + const eiffel = buildPlace({ id: 2, name: 'Eiffel Tower' }); + const assignments = { + '10': [buildAssignment({ day_id: 10, place_id: louvre.id, place: louvre })], + '11': [buildAssignment({ day_id: 11, place_id: eiffel.id, place: eiffel })], + }; + render(); + // Focusing the "from" field opens the quick picks — day 1's place only. + const [fromInput] = screen.getAllByPlaceholderText('Search stop or station…'); + await userEvent.click(fromInput); + expect(screen.getByText('Louvre')).toBeInTheDocument(); + expect(screen.queryByText('Eiffel Tower')).not.toBeInTheDocument(); + }); + it('FE-PLANNER-TRANSMODAL-024: editing shows no Manual/Automated switch', () => { const res = buildReservation({ title: 'My Train', type: 'train' }); render(); diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index 12bcdfab4..725487340 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -13,7 +13,7 @@ import { useAddonStore } from '../../store/addonStore' import { formatDate, splitReservationDateTime, resolveDayId } from '../../utils/formatters' import { openFile } from '../../utils/fileDownload' import apiClient from '../../api/client' -import type { Day, Place, Accommodation, Reservation, ReservationEndpoint, TripFile, BudgetItem } from '../../types' +import type { Day, Place, Accommodation, Reservation, ReservationEndpoint, TripFile, BudgetItem, AssignmentsMap } from '../../types' import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs' import { BookingCostsSection } from './BookingCostsSection' import type { BookingExpenseRequest } from './BookingCostsSection.types' @@ -151,14 +151,18 @@ interface TransportModalProps { prefill?: BookingReviewDraft | null /** Data for the Automated (public transit) mode's quick picks. */ places?: Place[] + /** Day→assignments map, used to scope the quick picks to the chosen day (#1460). */ + assignments?: AssignmentsMap accommodations?: Accommodation[] /** Open directly in the Automated public-transit mode (day-header tram button, "change route"). */ initialAutomated?: boolean + /** Transit search needs real dates to depart on, so the Automated mode is hidden on a dateless trip. */ + tripHasDates?: boolean /** Pre-seed the transit search — used by "change route" on an existing journey. */ transitPrefill?: { from?: PickedPlace | null; to?: PickedPlace | null } | null } -export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense, prefill = null, places = [], accommodations = [], initialAutomated = false, transitPrefill = null }: TransportModalProps) { +export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense, prefill = null, places = [], assignments = {}, accommodations = [], initialAutomated = false, transitPrefill = null, tripHasDates = true }: TransportModalProps) { const { t, locale } = useTranslation() const toast = useToast() const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget')) @@ -604,8 +608,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel } > {/* Manual vs Automated creation switch (#1065) — creating only; editing a - journey re-enters via "change route" with the switch hidden. */} - {!reservation && ( + journey re-enters via "change route" with the switch hidden. Without + trip dates there is nothing to plan a departure against, so Automated + is not offered and only the manual form shows. */} + {!reservation && tripHasDates && (
{([['manual', t('transport.modeManual')], ['automated', t('transport.modeAutomated')]] as const).map(([m, label]) => { const active = (m === 'automated') === automated @@ -639,11 +645,16 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel {(() => { const transitDay = days.find(d => d.id === Number(form.start_day_id)) if (!transitDay) return
{t('transit.pickDay')}
+ // Quick picks offer the chosen day's itinerary, not the whole trip (#1460). + const dayPlaces = (assignments[String(transitDay.id)] || []) + .slice().sort((a, b) => a.order_index - b.order_index) + .map(a => places.find(p => p.id === a.place_id)) + .filter((p): p is Place => p != null) return ( onSave(payload as Record & { title: string })} initialFrom={transitPrefill?.from ?? null} diff --git a/client/src/components/Planner/TripWarningsBanner.tsx b/client/src/components/Planner/TripWarningsBanner.tsx index a5c6b4cbf..e32f5d072 100644 --- a/client/src/components/Planner/TripWarningsBanner.tsx +++ b/client/src/components/Planner/TripWarningsBanner.tsx @@ -1,11 +1,18 @@ import { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' import { AlertTriangle, Info, AlertCircle } from 'lucide-react' import { pluginsApi } from '../../api/client' +import { usePluginStore } from '../../store/pluginStore' /** * Shows validation/warning contributions from `warningProvider` plugins (#1429). * Self-contained + fail-safe: the server skips any slow/failing provider, so this * only ever adds rows; it renders nothing (and takes no space) when there are none. + * + * Placement: a warning from a plugin that owns a trip-page tab in this planner + * renders as a compact chip in the navbar centre (click jumps to that tab); all + * other warnings float above the content at the BOTTOM of the planner, so neither + * kind ever covers the map toolbar or displaces the working area up top. */ type Warning = { pluginId: string; level: 'info' | 'warning' | 'error'; message: string } @@ -15,8 +22,14 @@ const STYLE = { error: { Icon: AlertCircle, color: 'var(--danger)', bg: 'var(--danger-soft)' }, } as const -export default function TripWarningsBanner({ tripId }: { tripId: number }) { +export default function TripWarningsBanner({ tripId, onOpenPluginTab }: { tripId: number; onOpenPluginTab?: (pluginId: string) => void }) { const [warnings, setWarnings] = useState([]) + const [navSlot, setNavSlot] = useState(null) + const [isDesktop, setIsDesktop] = useState( + () => typeof window !== 'undefined' && (window.matchMedia?.('(min-width: 768px)')?.matches ?? true) + ) + const plugins = usePluginStore((s) => s.plugins) + useEffect(() => { if (!Number.isFinite(tripId)) { setWarnings([]); return } let cancelled = false @@ -26,21 +39,62 @@ export default function TripWarningsBanner({ tripId }: { tripId: number }) { return () => { cancelled = true } }, [tripId]) + // The navbar renders in the same commit as the planner, so the slot exists by + // the time effects run; it is display:none'd with the navbar below md. + useEffect(() => { setNavSlot(document.getElementById('trek-nav-center-slot')) }, []) + useEffect(() => { + const mq = window.matchMedia?.('(min-width: 768px)') + if (!mq) return + const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches) + mq.addEventListener('change', handler) + return () => mq.removeEventListener('change', handler) + }, []) + if (warnings.length === 0) return null - // A non-blocking overlay pinned to the top of the (fixed) planner content region: - // the wrapper ignores pointer events so it never covers the map/panels, and only - // the warning pills themselves are interactive. + + const tripPageIds = new Set(plugins.filter((p) => p.type === 'trip-page').map((p) => p.id)) + const chips = navSlot && isDesktop ? warnings.filter((w) => tripPageIds.has(w.pluginId)) : [] + const floating = warnings.filter((w) => !chips.includes(w)) + return ( -
- {warnings.map((w, i) => { - const s = STYLE[w.level] ?? STYLE.warning - return ( -
- - {w.message} -
- ) - })} -
+ <> + {navSlot && chips.length > 0 && createPortal( + chips.map((w, i) => { + const s = STYLE[w.level] ?? STYLE.warning + return ( + + ) + }), + navSlot + )} + {floating.length > 0 && ( +
+ {floating.map((w, i) => { + const s = STYLE[w.level] ?? STYLE.warning + return ( +
+ + {w.message} +
+ ) + })} +
+ )} + ) } diff --git a/client/src/components/Plugins/PluginContributions.tsx b/client/src/components/Plugins/PluginContributions.tsx new file mode 100644 index 000000000..d9b87920c --- /dev/null +++ b/client/src/components/Plugins/PluginContributions.tsx @@ -0,0 +1,125 @@ +import { useCallback, useEffect, useState } from 'react' +import { X } from 'lucide-react' +import { pluginsApi, type ViewContribution } from '../../api/client' +import { useToast } from '../shared/Toast' +import PluginFrame from './PluginFrame' + +/** + * Host-rendered plugin contributions in a native planner view (#plugins, + * tableContributor hook). The server has already normalized + bounded every field + * (label/value length, http/https/mailto-only urls, enum tone/target), so this only + * renders trusted primitives — a column is text/link, an action is a labelled button + * that opens the plugin's sandboxed frame or calls one of its routes. Plugin-authored + * markup NEVER runs inline; it only ever runs inside the opaque-origin PluginFrame. + */ +export type ViewName = 'reservations' | 'transports' | 'places' | 'day' | 'costs' | 'packing' | 'files' | 'todos' +type Column = Extract +type Action = Extract + +const EMPTY: ViewContribution[] = [] + +/** Fetch a view's contributions once (keyed on tripId) and bucket them by entityId. + * Fail-safe: any error yields an empty lookup, so a plugin can never block or break + * the native view — it can only ADD cells/buttons. */ +export function usePluginViewContributions(view: ViewName, tripId: string | number | null | undefined) { + const [byEntity, setByEntity] = useState>(new Map()) + useEffect(() => { + if (tripId == null) { setByEntity(new Map()); return } + let cancelled = false + pluginsApi.viewContributions(view, tripId) + .then(({ contributions }) => { + if (cancelled) return + const m = new Map() + for (const c of contributions || []) { + const arr = m.get(c.entityId) + if (arr) arr.push(c); else m.set(c.entityId, [c]) + } + setByEntity(m) + }) + .catch(() => { if (!cancelled) setByEntity(new Map()) }) + return () => { cancelled = true } + }, [view, tripId]) + return useCallback((entityId: number): ViewContribution[] => byEntity.get(entityId) ?? EMPTY, [byEntity]) +} + +const TONE_CLASS: Record = { + default: 'text-content-muted', + success: 'text-success', + warn: 'text-warning', + danger: 'text-danger', +} + +/** Extra read-only cells (label + value/link) for one entity, in the card/row body. */ +export function PluginColumns({ items }: { items: ViewContribution[] }) { + const cols = items.filter((c): c is Column => c.kind === 'column') + if (!cols.length) return null + return ( +
+ {cols.map((c) => ( +
+ {c.label} + {c.url + ? e.stopPropagation()}>{c.value ?? '↗'} + : {c.value}} +
+ ))} +
+ ) +} + +/** The plugin-contributed columns + actions strip appended to the bottom of an + * entity row/card. Renders nothing (zero change) when no plugin contributes. */ +export function PluginCardFooter({ items, tripId }: { items: ViewContribution[]; tripId: number }) { + if (!items.length) return null + return ( +
+ +
+
+ ) +} + +/** Labelled action buttons for one entity — a route action calls the plugin route; + * a frame action opens the plugin's sandboxed UI in a modal. */ +export function PluginActions({ items, tripId, className }: { items: ViewContribution[]; tripId: string | number | null; className?: string }) { + const actions = items.filter((c): c is Action => c.kind === 'action') + const [frame, setFrame] = useState<{ pluginId: string; label: string } | null>(null) + const toast = useToast() + if (!actions.length) return null + + const run = (a: Action, e: React.MouseEvent) => { + e.stopPropagation() + if (a.target.kind === 'frame') { setFrame({ pluginId: a.pluginId, label: a.label }); return } + void pluginsApi.invoke(a.pluginId, a.target.sub, { method: a.target.method }) + .then(() => toast.success(a.label)) + .catch(() => toast.error(a.label)) + } + + return ( + <> + {actions.map((a) => ( + + ))} + {frame && ( +
setFrame(null)}> +
e.stopPropagation()}> +
+ {frame.label} + +
+
+ +
+
+
+ )} + + ) +} diff --git a/client/src/components/Plugins/PluginFrame.test.tsx b/client/src/components/Plugins/PluginFrame.test.tsx index e9afc6bcf..5aa49510e 100644 --- a/client/src/components/Plugins/PluginFrame.test.tsx +++ b/client/src/components/Plugins/PluginFrame.test.tsx @@ -1,17 +1,22 @@ -// FE-PLUGINS-FRAME-001 to 004 -import { render, cleanup, waitFor } from '@testing-library/react'; +// FE-PLUGINS-FRAME-001 to 012 +import { render, cleanup, waitFor, fireEvent, screen, act } from '@testing-library/react'; import PluginFrame from './PluginFrame'; const navigate = vi.fn(); const toast = { info: vi.fn(), success: vi.fn(), warning: vi.fn(), error: vi.fn() }; const invoke = vi.fn((..._args: unknown[]) => Promise.resolve({ ok: true })); +const wsListeners = new Set<(ev: Record) => void>(); vi.mock('react-router-dom', () => ({ useNavigate: () => navigate })); vi.mock('../shared/Toast', () => ({ useToast: () => toast })); -vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en' }) })); +vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en', t: (k: string) => k }) })); vi.mock('../../store/authStore', () => ({ useAuthStore: (sel: (s: unknown) => unknown) => sel({ user: { id: 7, username: 'ada', avatar_url: null, role: 'admin' } }) })); vi.mock('../../store/settingsStore', () => ({ useSettingsStore: (sel: (s: unknown) => unknown) => sel({ settings: { default_currency: 'EUR', time_format: '24h', distance_unit: 'metric', temperature_unit: 'celsius' } }) })); vi.mock('../../api/client', () => ({ pluginsApi: { invoke: (id: string, sub: string, init?: unknown) => invoke(id, sub, init) } })); +vi.mock('../../api/websocket', () => ({ + addListener: (fn: (ev: Record) => void) => wsListeners.add(fn), + removeListener: (fn: (ev: Record) => void) => wsListeners.delete(fn), +})); function fromFrame(frame: HTMLIFrameElement, data: unknown) { window.dispatchEvent(new MessageEvent('message', { source: frame.contentWindow, data } as MessageEventInit)); @@ -22,6 +27,7 @@ afterEach(() => { navigate.mockClear(); Object.values(toast).forEach((f) => f.mockClear()); invoke.mockClear(); + wsListeners.clear(); }); describe('PluginFrame', () => { @@ -83,6 +89,37 @@ describe('PluginFrame', () => { expect(JSON.stringify(ctx)).not.toContain('@'); // no email leaked }); + it('FE-PLUGINS-FRAME-008: a day-detail host passes the open day (and place stays null)', () => { + const { container } = render(); + const iframe = container.querySelector('iframe')!; + const posted: Array> = []; + (iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record); + + fromFrame(iframe, { type: 'trek:context:request' }); + + const ctx = posted.find((m) => m.type === 'trek:context') as Record | undefined; + expect(ctx).toBeTruthy(); + expect(ctx!.tripId).toBe('1'); + expect(ctx!.dayId).toBe('12'); + expect(ctx!.placeId).toBeNull(); + }); + + it('FE-PLUGINS-FRAME-014: a reservation-detail host passes the open reservation (and day/place stay null)', () => { + const { container } = render(); + const iframe = container.querySelector('iframe')!; + const posted: Array> = []; + (iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record); + + fromFrame(iframe, { type: 'trek:context:request' }); + + const ctx = posted.find((m) => m.type === 'trek:context') as Record | undefined; + expect(ctx).toBeTruthy(); + expect(ctx!.tripId).toBe('1'); + expect(ctx!.reservationId).toBe('88'); + expect(ctx!.dayId).toBeNull(); + expect(ctx!.placeId).toBeNull(); + }); + it('FE-PLUGINS-FRAME-006: context mirrors the host appearance state (scheme/density/flags)', () => { const { container } = render(); const iframe = container.querySelector('iframe')!; @@ -101,4 +138,89 @@ describe('PluginFrame', () => { reducedMotion: false, }); }); + + it('FE-PLUGINS-FRAME-007: fill mode pins the frame to 100% height and ignores trek:resize', () => { + const { container } = render(); + const iframe = container.querySelector('iframe')!; + act(() => { fromFrame(iframe, { type: 'trek:resize', height: 480 }); }); + expect(iframe.style.height).toBe('100%'); + }); + + it('FE-PLUGINS-FRAME-008: without fill, trek:resize drives the frame height (widget self-sizing)', () => { + const { container } = render(); + const iframe = container.querySelector('iframe')!; + act(() => { fromFrame(iframe, { type: 'trek:resize', height: 480 }); }); + expect(iframe.style.height).toBe('480px'); + }); + + it('FE-PLUGINS-FRAME-009: trek:confirm renders the native dialog and answers over the bridge', async () => { + const { container } = render(); + const iframe = container.querySelector('iframe')!; + const posted: Array> = []; + (iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record); + + fromFrame(iframe, { type: 'trek:confirm', requestId: 'c1', message: 'Delete everything?', confirmLabel: 'Yes, wipe it' }); + const confirmBtn = await screen.findByText('Yes, wipe it'); + fireEvent.click(confirmBtn); + + const result = posted.find((m) => m.type === 'trek:confirm:result') as Record | undefined; + expect(result).toMatchObject({ requestId: 'c1', confirmed: true }); + }); + + it('FE-PLUGINS-FRAME-010: trek:openExternal opens only real web URLs in a noopener tab', () => { + const open = vi.spyOn(window, 'open').mockReturnValue(null); + const { container } = render(); + const iframe = container.querySelector('iframe')!; + + fromFrame(iframe, { type: 'trek:openExternal', url: 'javascript:alert(1)' }); + fromFrame(iframe, { type: 'trek:openExternal', url: 'not a url' }); + expect(open).not.toHaveBeenCalled(); + + fromFrame(iframe, { type: 'trek:openExternal', url: 'https://example.com/docs' }); + expect(open).toHaveBeenCalledWith('https://example.com/docs', '_blank', 'noopener,noreferrer'); + open.mockRestore(); + }); + + it('FE-PLUGINS-FRAME-011: forwards core-event names for the trip in view, payload-free', () => { + const { container } = render(); + const iframe = container.querySelector('iframe')!; + const posted: Array> = []; + (iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record); + // The bridge only forwards to the original document (first load). + fireEvent.load(iframe); + + expect(wsListeners.size).toBe(1); + const emit = [...wsListeners][0]; + emit({ type: 'place_created', tripId: 99, place: { secret: true } }); // other trip -> dropped + emit({ type: 'place_created', tripId: 42, place: { secret: true } }); + + const events = posted.filter((m) => m.type === 'trek:event'); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ event: 'place_created', tripId: '42' }); + expect(JSON.stringify(events[0])).not.toContain('secret'); // names only, never payloads + }); + + it('FE-PLUGINS-FRAME-012: trek:notify clamps a plugin-supplied duration', () => { + const { container } = render(); + const iframe = container.querySelector('iframe')!; + fromFrame(iframe, { type: 'trek:notify', level: 'info', message: 'hi', duration: 999999 }); + expect(toast.info).toHaveBeenCalledWith('hi', 15000); + // NaN must not slip through the clamp as a sticky toast. + fromFrame(iframe, { type: 'trek:notify', level: 'info', message: 'ho', duration: NaN }); + expect(toast.info).toHaveBeenLastCalledWith('ho'); + }); + + it('FE-PLUGINS-FRAME-013: swapping pluginId in place restarts the bridge for the new plugin', () => { + const { container, rerender } = render(); + fireEvent.load(container.querySelector('iframe')!); + + rerender(); + const next = container.querySelector('iframe')!; // keyed by pluginId -> fresh element + expect(next.getAttribute('src')).toBe('/plugin-frame/beta/index.html'); + act(() => { fireEvent.load(next); }); + + // Without the per-plugin reset this would be refused as a "navigated" frame. + fromFrame(next, { type: 'trek:navigate', to: '/dashboard' }); + expect(navigate).toHaveBeenCalledWith('/dashboard'); + }); }); diff --git a/client/src/components/Plugins/PluginFrame.tsx b/client/src/components/Plugins/PluginFrame.tsx index 207362f66..d5e6889c8 100644 --- a/client/src/components/Plugins/PluginFrame.tsx +++ b/client/src/components/Plugins/PluginFrame.tsx @@ -4,7 +4,9 @@ import { useTranslation } from '../../i18n' import { useAuthStore } from '../../store/authStore' import { useSettingsStore } from '../../store/settingsStore' import { useToast } from '../shared/Toast' +import ConfirmDialog from '../shared/ConfirmDialog' import { pluginsApi } from '../../api/client' +import { addListener, removeListener } from '../../api/websocket' // The design-token contract handed to plugins (#4 richer context): non-secret CSS // values, resolved for the CURRENT theme, so a plugin can match TREK exactly (and @@ -81,6 +83,17 @@ interface PluginFrameProps { tripId?: string | null /** The place in view — set for a place-detail slot so the plugin can scope to it. */ placeId?: string | null + /** The day in view — set for a day-detail slot so the plugin can scope to it. */ + dayId?: string | null + /** The reservation in view — set for a reservation-detail slot so the plugin can scope to it. */ + reservationId?: string | null + /** + * Full-page hosts (trip tab, /plugins/:id) set this so the frame always fills + * its container. Without it a kit-based plugin auto-reports its content height + * (trek:resize) and the page collapses to a floating island with dead space + * below — right for dashboard widgets, wrong for a page. + */ + fill?: boolean className?: string title?: string } @@ -89,11 +102,22 @@ type Inbound = | { type: 'trek:ready' } | { type: 'trek:context:request' } | { type: 'trek:navigate'; to: string } - | { type: 'trek:notify'; level?: 'info' | 'success' | 'warning' | 'error'; message?: string } + | { type: 'trek:notify'; level?: 'info' | 'success' | 'warning' | 'error'; message?: string; duration?: number } | { type: 'trek:resize'; height?: number } | { type: 'trek:invoke'; requestId: string; sub: string; method?: string; body?: unknown } + | { type: 'trek:confirm'; requestId: string; title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean } + | { type: 'trek:openExternal'; url?: string } -export default function PluginFrame({ pluginId, tripId = null, placeId = null, className, title }: PluginFrameProps) { +interface ConfirmRequest { + requestId: string + title?: string + message?: string + confirmLabel?: string + cancelLabel?: string + danger: boolean +} + +export default function PluginFrame({ pluginId, tripId = null, placeId = null, dayId = null, reservationId = null, fill = false, className, title }: PluginFrameProps) { const frameRef = useRef(null) // A sandboxed frame may navigate ITSELF (connect-src can't stop that), and its // window identity keeps matching our iframe afterwards. Track loads and refuse @@ -107,12 +131,24 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, c const { locale } = useTranslation() const navigate = useNavigate() const toast = useToast() + // useToast returns a fresh object every render; keep it out of the effect deps + // (via this ref) or the bridge effect would tear down and re-post the context on + // EVERY parent re-render instead of only when the context inputs change. + const toastRef = useRef(toast) + toastRef.current = toast const userId = useAuthStore((s) => s.user?.id) const userName = useAuthStore((s) => s.user?.username ?? null) const userAvatar = useAuthStore((s) => s.user?.avatar_url ?? null) const isAdmin = useAuthStore((s) => s.user?.role === 'admin') const settings = useSettingsStore((s) => s.settings) const [height, setHeight] = useState(null) + // A host-rendered ConfirmDialog on the plugin's behalf: sandboxed frames have no + // allow-modals and can't overlay the host, so destructive plugin actions get the + // same native confirm every TREK feature uses. One at a time; extras are refused. + // The ref mirrors the state so the message handler can gate + answer without + // side effects inside a setState updater (StrictMode runs updaters twice). + const [confirmReq, setConfirmReq] = useState(null) + const confirmReqRef = useRef(null) // opaque frame -> targetOrigin must be '*'. Hoisted so the iframe's onLoad can // deliver the context too: the trek:ready handshake alone is racy — if the frame @@ -123,9 +159,14 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, c type: 'trek:context', tripId, placeId, + dayId, + reservationId, userId: userId != null ? String(userId) : null, theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light', locale, + // Mirror of what TranslationProvider stamps on — an RTL host (Arabic) + // wants RTL plugin UIs too, and the frame can't read our DOM to find out. + dir: (document.documentElement.getAttribute('dir') === 'rtl' ? 'rtl' : 'ltr') as 'rtl' | 'ltr', hostOrigin: window.location.origin, // #4 richer context — non-secret display data so plugins render natively: // who the user is (name/avatar/isAdmin — never email/role beyond a boolean), @@ -143,7 +184,7 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, c timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, }, tokens: readThemeTokens(), - }), [tripId, placeId, userId, locale, userName, userAvatar, isAdmin, settings]) + }), [tripId, placeId, dayId, reservationId, userId, locale, userName, userAvatar, isAdmin, settings]) useEffect(() => { const frame = frameRef.current @@ -175,11 +216,47 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, c case 'trek:notify': { const text = String(msg.message ?? '').slice(0, 200) const level = msg.level ?? 'info' - if (text) (toast[level] ?? toast.info)(text) + if (!text) break + const t = toastRef.current + const show = t[level] ?? t.info + // Optional duration, clamped so a plugin can neither flash nor park a + // toast (isFinite: NaN would slip through the clamp as duration 0 = sticky). + if (Number.isFinite(msg.duration)) show(text, Math.min(Math.max(msg.duration as number, 1500), 15000)) + else show(text) + break + } + case 'trek:confirm': { + if (typeof msg.requestId !== 'string' || !msg.requestId) break + if (confirmReqRef.current) { + // One dialog at a time — answer the newcomer 'not confirmed' right away. + post({ type: 'trek:confirm:result', requestId: msg.requestId, confirmed: false }) + break + } + const req: ConfirmRequest = { + requestId: msg.requestId, + title: typeof msg.title === 'string' ? msg.title.slice(0, 120) : undefined, + message: typeof msg.message === 'string' ? msg.message.slice(0, 500) : undefined, + confirmLabel: typeof msg.confirmLabel === 'string' ? msg.confirmLabel.slice(0, 40) : undefined, + cancelLabel: typeof msg.cancelLabel === 'string' ? msg.cancelLabel.slice(0, 40) : undefined, + danger: msg.danger !== false, + } + confirmReqRef.current = req + setConfirmReq(req) + break + } + case 'trek:openExternal': { + // The sandbox has no allow-popups, so the HOST opens the link — but only + // real web URLs, never javascript:/data:/file: or anything else. + try { + const u = new URL(String(msg.url ?? '')) + if (u.protocol === 'https:' || u.protocol === 'http:') { + window.open(u.href, '_blank', 'noopener,noreferrer') + } + } catch { /* not a URL — ignore */ } break } case 'trek:resize': - if (typeof msg.height === 'number' && msg.height > 0) setHeight(Math.min(msg.height, 2000)) + if (!fill && typeof msg.height === 'number' && msg.height > 0) setHeight(Math.min(msg.height, 2000)) break case 'trek:invoke': { // The plugin's own route, called host-side with the user's session. @@ -197,6 +274,29 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, c window.addEventListener('message', onMessage) + // Live locale/format sync: this effect re-runs whenever buildContext's inputs + // (locale, settings, trip/place) change, so a loaded frame gets the fresh + // context pushed instead of staying stale until reload. First delivery is + // handled by onLoad; navigated frames (loads > 1) are never re-bridged. + if (loadsRef.current === 1) post(context()) + + // Forward event *names* for the trip in view, mirroring the server-side events + // surface: only { event, tripId }, never payloads — the frame's user is already + // looking at this trip. Core events plus the plugin's OWN namespaced broadcasts + // (plugin:{id}:*) pass; other plugins' broadcasts don't. Trip events only reach + // the socket while a planner has the trip joined, so this is a planner-side + // refresh signal — dashboard widgets still poll. + let wsForward: ((ev: Record) => void) | null = null + if (tripId) { + wsForward = (ev) => { + if (loadsRef.current !== 1) return + if (!ev || typeof ev.type !== 'string' || ev.tripId == null || String(ev.tripId) !== tripId) return + if (ev.type.startsWith('plugin:') && !ev.type.startsWith(`plugin:${pluginId}:`)) return + post({ type: 'trek:event', event: ev.type, tripId }) + } + addListener(wsForward) + } + // The frame is opaque-origin and can't read our DOM, and we otherwise send the // context (incl. theme + tokens) only once on trek:ready — so a plugin can't // follow an in-app appearance change. Watch the element for anything @@ -229,23 +329,63 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, c attributeFilter: ['class', 'style', 'data-scheme', 'data-density', 'data-no-transparency', 'data-reduce-motion'], }) - return () => { window.removeEventListener('message', onMessage); themeObserver.disconnect() } - }, [pluginId, navigate, toast, postFrame, buildContext]) + return () => { + window.removeEventListener('message', onMessage) + themeObserver.disconnect() + if (wsForward) removeListener(wsForward) + } + }, [pluginId, tripId, fill, navigate, postFrame, buildContext]) + + // Hosts swap pluginId in place (tab bar, /plugins/:id route) — the iframe below + // is keyed so the document is a fresh first load, and the per-plugin bridge + // state must start over with it or the new plugin would be refused as a + // "navigated" frame (loads > 1) and an open confirm would leak across plugins. + useEffect(() => { + loadsRef.current = 0 + setHeight(null) + confirmReqRef.current = null + setConfirmReq(null) + }, [pluginId]) + + const answerConfirm = (confirmed: boolean) => { + const req = confirmReqRef.current + if (req) postFrame({ type: 'trek:confirm:result', requestId: req.requestId, confirmed }) + confirmReqRef.current = null + setConfirmReq(null) + } + + // The dialog title always leads with the host-controlled plugin name, so a + // plugin cannot dress its confirm up as a TREK system dialog. + const pluginLabel = title || pluginId + const confirmTitle = confirmReq?.title ? `${pluginLabel} — ${confirmReq.title}` : pluginLabel return ( -